From c548ec9906947c72743e611254a6557e8e8d057c Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 5 Feb 2025 19:01:44 +0900
Subject: refactor(frontend): verbatimModuleSyntaxを有効化 (#15323)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip

* revert unnecessary changes

* wip

* refactor(frontend): enforce verbatimModuleSyntax

* fix

* refactor(frontend-shared): enforce verbatimModuleSyntax

* wip

* refactor(frontend-embed): enforce verbatimModuleSyntax

* enforce consistent-type-imports

* fix lint config

* attemt to fix ci

* fix lint

* fix

* fix

* fix
---
 packages/frontend/src/components/MkNoteDetailed.vue | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 9d3374d433..fa1358da3d 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -225,7 +225,8 @@ import MkPoll from '@/components/MkPoll.vue';
 import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
-import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import { pleaseLogin } from '@/scripts/please-login.js';
+import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { checkWordMute } from '@/scripts/check-word-mute.js';
 import { userPage } from '@/filters/user.js';
 import { notePage } from '@/filters/note.js';
@@ -246,12 +247,13 @@ import { claimAchievement } from '@/scripts/achievements.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
-import MkPagination, { type Paging } from '@/components/MkPagination.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import type { Paging } from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
-import { type Keymap } from '@/scripts/hotkey.js';
+import type { Keymap } from '@/scripts/hotkey.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
-- 
cgit v1.2.3-freya


From d8c4908aa55ddf147219d9c6eb889bc1b5002454 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 15 Feb 2025 13:01:06 +0900
Subject: enhance(frontend): リアクション時に確認ダイアログを出せるように
 (#15174)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): リアクション時に確認ダイアログを出せるように

* Update Changelog

* indent

* fix
---
 CHANGELOG.md                                                  |  1 +
 locales/index.d.ts                                            |  8 ++++++++
 locales/ja-JP.yml                                             |  2 ++
 packages/frontend/src/components/MkNote.vue                   | 11 ++++++++++-
 packages/frontend/src/components/MkNoteDetailed.vue           | 11 ++++++++++-
 .../frontend/src/components/MkReactionsViewer.reaction.vue    |  9 +++++++++
 packages/frontend/src/pages/settings/general.vue              |  2 ++
 packages/frontend/src/store.ts                                |  4 ++++
 8 files changed, 46 insertions(+), 2 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc2f7e0b08..cd601c7e29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 - Enhance: クライアントエラー画面の多言語対応
 - Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441'
 - Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
+- Enhance: リアクションする際に確認ダイアログを表示できるように
 - Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
 - Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正
 - Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378`
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 4fe605490e..c7996b2ca9 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5254,6 +5254,14 @@ export interface Locale extends ILocale {
      * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。
      */
     "federationDisabled": string;
+    /**
+     * リアクションする際に確認する
+     */
+    "confirmOnReact": string;
+    /**
+     * " {emoji} " をリアクションしますか？
+     */
+    "reactAreYouSure": ParameterizedString<"emoji">;
     "_accountSettings": {
         /**
          * コンテンツの表示にログインを必須にする
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 57b11e9e04..1aed7c21ae 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1309,6 +1309,8 @@ availableRoles: "利用可能なロール"
 acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
 federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
 federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
+confirmOnReact: "リアクションする際に確認する"
+reactAreYouSure: "\" {emoji} \" をリアクションしますか？"
 
 _accountSettings:
   requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 919734f763..193dfe5b7e 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -489,7 +489,16 @@ function react(): void {
 		}
 	} else {
 		blur();
-		reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
+		reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
+			if (defaultStore.state.confirmOnReact) {
+				const confirm = await os.confirm({
+					type: 'question',
+					text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
+				});
+
+				if (confirm.canceled) return;
+			}
+
 			sound.playMisskeySfx('reaction');
 
 			if (props.mock) {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index fa1358da3d..835a663868 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -452,7 +452,16 @@ function react(): void {
 		}
 	} else {
 		blur();
-		reactionPicker.show(reactButton.value ?? null, note.value, reaction => {
+		reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
+			if (defaultStore.state.confirmOnReact) {
+				const confirm = await os.confirm({
+					type: 'question',
+					text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
+				});
+
+				if (confirm.canceled) return;
+			}
+
 			sound.playMisskeySfx('reaction');
 
 			misskeyApi('notes/reactions/create', {
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index b65038aadc..41e475eade 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -90,6 +90,15 @@ async function toggleReaction() {
 			}
 		});
 	} else {
+		if (defaultStore.state.confirmOnReact) {
+			const confirm = await os.confirm({
+				type: 'question',
+				text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }),
+			});
+
+			if (confirm.canceled) return;
+		}
+
 		sound.playMisskeySfx('reaction');
 
 		if (mock) {
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 1ee7909aa8..4449d6169f 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -170,6 +170,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
 				<MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch>
 				<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch>
+				<MkSwitch v-model="confirmOnReact">{{ i18n.ts.confirmOnReact }}</MkSwitch>
 			</div>
 			<MkSelect v-model="serverDisconnectedBehavior">
 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -320,6 +321,7 @@ const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHori
 const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
 const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
 const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia'));
+const confirmOnReact = computed(defaultStore.makeGetterSetter('confirmOnReact'));
 const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
 
 watch(lang, () => {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 16b900e052..e2243e067a 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -479,6 +479,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
+	confirmOnReact: {
+		where: 'device',
+		default: false,
+	},
 
 	sound_masterVolume: {
 		where: 'device',
-- 
cgit v1.2.3-freya


From 28b40691d5e26571d0cd52a98c60195534cc4407 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 26 Feb 2025 15:51:28 +0900
Subject: fix(frontend):
 リノート経由でノートの詳細情報を見るとき、クリップ一覧がappearNoteのものになるように
 (#15417)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): リノート経由でノートの詳細情報を見るとき、クリップ一覧がappearNoteのものになるように

* Update Changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                         | 1 +
 packages/frontend/src/components/MkNoteDetailed.vue  | 2 +-
 packages/frontend/src/components/MkRemoteCaution.vue | 4 ++--
 packages/frontend/src/pages/note.vue                 | 8 +++++---
 4 files changed, 9 insertions(+), 6 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index db29385d73..d51369f4bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
 - Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
 - Enhance: リアクションする際に確認ダイアログを表示できるように
 - Enhance: CWの注釈で入力済みの文字数を表示
+- Fix: ノートページで、クリップ一覧が表示されないことがある問題を修正
 - Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
 - Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正
 - Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378`
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 835a663868..d5bd4ad133 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -257,7 +257,7 @@ import type { Keymap } from '@/scripts/hotkey.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
-	initialTab: string;
+	initialTab?: string;
 }>(), {
 	initialTab: 'replies',
 });
diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue
index a56a4b1671..2d3b7d107d 100644
--- a/packages/frontend/src/components/MkRemoteCaution.vue
+++ b/packages/frontend/src/components/MkRemoteCaution.vue
@@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
+<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a v-if="href" :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
 </template>
 
 <script lang="ts" setup>
 import { i18n } from '@/i18n.js';
 
 defineProps<{
-	href: string;
+	href?: string;
 }>();
 </script>
 
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index e6b4a0b222..0791c1343b 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -63,6 +63,7 @@ import { dateString } from '@/filters/date.js';
 import MkClipPreview from '@/components/MkClipPreview.vue';
 import { defaultStore } from '@/store.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
+import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { serverContext, assertServerContext } from '@/server-context.js';
 import { $i } from '@/account.js';
 
@@ -132,10 +133,11 @@ function fetchNote() {
 		noteId: props.noteId,
 	}).then(res => {
 		note.value = res;
+		const appearNote = getAppearNote(res);
 		// 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く
-		if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) {
+		if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) {
 			misskeyApi('notes/clips', {
-				noteId: note.value.id,
+				noteId: appearNote.id,
 			}).then((_clips) => {
 				clips.value = _clips;
 			});
@@ -170,7 +172,7 @@ definePageMetadata(() => ({
 		avatar: note.value.user,
 		path: `/notes/${note.value.id}`,
 		share: {
-			title: i18n.tsx.noteOf({ user: note.value.user.name }),
+			title: i18n.tsx.noteOf({ user: note.value.user.name ?? note.value.user.username }),
 			text: note.value.text,
 		},
 	} : {},
-- 
cgit v1.2.3-freya


From 01a3eabc4e1deb00b29398777d453d5b3583583b Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Mon, 3 Mar 2025 17:46:38 +0900
Subject: enhance(frontend):
 アニメーション設定で画面上のエフェクトも考慮するように (#15576)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): アニメーション設定で画面上のエフェクトも考慮するように

* Update Changelog
---
 CHANGELOG.md                                        | 1 +
 packages/frontend/src/components/MkEmojiPicker.vue  | 2 +-
 packages/frontend/src/components/MkNote.vue         | 2 +-
 packages/frontend/src/components/MkNoteDetailed.vue | 2 +-
 packages/frontend/src/components/MkPostForm.vue     | 2 +-
 packages/frontend/src/directives/ripple.ts          | 2 ++
 packages/frontend/src/scripts/get-note-menu.ts      | 6 +++---
 7 files changed, 10 insertions(+), 7 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index caf2ff49e5..9b733e46ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 ### Client
 - Enhance: モデレーターがセンシティブ設定を変更する際に確認ダイアログを出すように
 - Enhance: ユーザーページのノート一覧と前後のノート表示でチャンネルのノートを含めるように
+- Enhance: 「UIのアニメーションを減らす」で画面上のエフェクトも減らせるように
 - Fix: 削除して編集の削除タイミングを投稿後になるように `#14498`
 - Fix: フォローされたときのメッセージがちらつくことがある問題を修正
 - Fix: 投稿ダイアログがサイズ限界を超えた際にスクロールできない問題を修正
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 5da161dae8..62a1000674 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -413,7 +413,7 @@ function computeButtonTitle(ev: MouseEvent): void {
 
 function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) {
 	const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
-	if (el) {
+	if (el && defaultStore.state.animation) {
 		const rect = el.getBoundingClientRect();
 		const x = rect.left + (el.offsetWidth / 2);
 		const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 193dfe5b7e..8e1d854660 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -479,7 +479,7 @@ function react(): void {
 			reaction: '❤️',
 		});
 		const el = reactButton.value;
-		if (el) {
+		if (el && defaultStore.state.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index d5bd4ad133..27d8a399cc 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -442,7 +442,7 @@ function react(): void {
 			reaction: '❤️',
 		});
 		const el = reactButton.value;
-		if (el) {
+		if (el && defaultStore.state.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 4e29f0c0d5..255aa6ca19 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -752,7 +752,7 @@ async function post(ev?: MouseEvent) {
 	if (ev) {
 		const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;
 
-		if (el) {
+		if (el && defaultStore.state.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts
index a043ff212d..99845c57c3 100644
--- a/packages/frontend/src/directives/ripple.ts
+++ b/packages/frontend/src/directives/ripple.ts
@@ -4,12 +4,14 @@
  */
 
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { defaultStore } from '@/store.js';
 import { popup } from '@/os.js';
 
 export default {
 	mounted(el, binding, vn) {
 		// 明示的に false であればバインドしない
 		if (binding.value === false) return;
+		if (!defaultStore.state.animation) return;
 
 		el.addEventListener('click', () => {
 			const rect = el.getBoundingClientRect();
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 4b7dc426f2..de7ed15675 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -550,7 +550,7 @@ export function getRenoteMenu(props: {
 			icon: 'ti ti-repeat',
 			action: () => {
 				const el = props.renoteButton.value;
-				if (el) {
+				if (el && defaultStore.state.animation) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
@@ -588,7 +588,7 @@ export function getRenoteMenu(props: {
 			icon: 'ti ti-repeat',
 			action: () => {
 				const el = props.renoteButton.value;
-				if (el) {
+				if (el && defaultStore.state.animation) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
@@ -639,7 +639,7 @@ export function getRenoteMenu(props: {
 					text: channel.name,
 					action: () => {
 						const el = props.renoteButton.value;
-						if (el) {
+						if (el && defaultStore.state.animation) {
 							const rect = el.getBoundingClientRect();
 							const x = rect.left + (el.offsetWidth / 2);
 							const y = rect.top + (el.offsetHeight / 2);
-- 
cgit v1.2.3-freya


From d30ddd4c2ebcacc0d0b49c74e8dfe05b5422ba2e Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 9 Mar 2025 12:34:08 +0900
Subject: Refine preferences (#15597)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* test

* wip rollup pluginでsearchIndexの情報生成

* wip

* SPDX

* wip: markerIdを自動付与

* rollupでビルド時・devモード時に毎回uuidを生成するように

* 開発サーバーでだけ必要な挙動は開発サーバーのみで

* 条件が逆

* wip: childrenの生成

* update comment

* update comment

* rename auto generated file

* hashをパスと行数から決定

* Update privacy.vue

* Update privacy.vue

* wip

* Update general.vue

* Update general.vue

* wip

* wip

* Update SearchMarker.vue

* wip

* Update profile.vue

* Update mute-block.vue

* Update mute-block.vue

* Update general.vue

* Update general.vue

* childrenがduplicate key errorを吐く問題をいったん解決

* マーカーの形を成形

* loggerを置きかえ

* とりあえず省略記法に対応

* Refactor and Format codes

* wip

* Update settings-search-index.ts

* wip

* wip

* とりあえず不確定要因の仮置きidを削除

* hashの生成を正規化（絶対パスになっていたのを緩和）

* pathの入力を省略可能に

* adminでもパス生成できるように

* Update settings-search-index.ts

* Update privacy.vue

* wip

* build searchIndex

* wip

* build

* Update general.vue

* build

* Update sounds.vue

* build

* build

* Update sounds.vue

* 🎨

* 🎨

* Update privacy.vue

* Update privacy.vue

* Update security.vue

* create-search-indexを多少改善

* build

* Update 2fa.vue

* wip

* 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義

* キャッシュはdevServerでなくても更新

* Revert "wip"

This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054.

* inlining

* wip

* Update theme.vue

* 🎨

* wip normalize

* Update theme.vue

* キャッシュのパス変換

* build

* wip

* wip

* Update SearchMarker.vue

* i18n.ts['key'] の形式が取り出せない問題のFix

* build

* 仮でpath入れ

* 必ず絶対パスが使われるように

* wip

* 🎨

* storybookビルド時はcreateSearchIndexをしない

* inliningの構造化

* format code

* Update index.vue

* wip

* wip

* 🎨

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* clean up

* wip

* wip

* wip

* Update rollup-plugin-unwind-css-module-class-name.test.ts

* Update navbar.vue

* clean up

* wip

* wip

* wip

* wip

* wip

* Update preferences-backups.vue

* Update common.ts

* Update preferences.ts

* wip

* wip

* wip

* wip

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* enhance: 検索で上下矢印を使用することで検索結果を移動できるように

* Update main-boot.ts

* refactor

* wip

* Update sounds.vue

* fix(frontend): PageWindowでSearchMarkerが動作するように

* enhance(frontend): SearchMarkerの点滅を一定時間で止める

* wip

* lint fix

* fix: 子要素監視が抜けていたのを修正

* アニメーションの回数はCSSで制御するように

* refactor

* enhance(frontend): 検索インデックス作成時のログを削減

* revert

* fix

* fix

* Update preferences.ts

* Update preferences.ts

* wip

* Update preferences.ts

* wip

* 🎨

* wip

* Update MkPreferenceContainer.vue

* wip

* Update preferences.ts

* wip

* Update preferences.ts

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* Update CHANGELOG.md

* Update preferences.ts

* Update deck-store.ts

* deckStoreをdefaultStoreに統合

* wip

* defaultStore -> store

* Update profile.ts

* wip

* refactor

* wip: plugin

* plugin

* plugin

* plugin

* Update plugin.ts

* wip

* Update plugin.vue

* Update preferences.ts

* Update main-boot.ts

* wip

* fix test

* Update plugin.vue

* Update plugin.vue

* Update utility.ts

* wip

* wip

* Update utility.ts

* wip

* wip

* clean up

* Update utility.ts

---------

Co-authored-by: tai-cha <dev@taichan.site>
Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
---
 CHANGELOG.md                                       |   3 +-
 locales/index.d.ts                                 |  80 ++++
 locales/ja-JP.yml                                  |  23 +
 packages/frontend/.storybook/preview.ts            |  10 +-
 ...lup-plugin-unwind-css-module-class-name.test.ts |  20 +-
 packages/frontend/src/boot/common.ts               |  60 +--
 packages/frontend/src/boot/main-boot.ts            | 128 +++++-
 .../frontend/src/components/MkAutocomplete.vue     |  13 +-
 packages/frontend/src/components/MkCaptcha.vue     |   4 +-
 packages/frontend/src/components/MkChart.vue       |   4 +-
 packages/frontend/src/components/MkCode.core.vue   |   4 +-
 packages/frontend/src/components/MkCode.vue        |   6 +-
 packages/frontend/src/components/MkContainer.vue   |  10 +-
 packages/frontend/src/components/MkContextMenu.vue |  10 +-
 .../frontend/src/components/MkCropperDialog.vue    |   8 +-
 .../src/components/MkDateSeparatedList.vue         |   6 +-
 .../frontend/src/components/MkDrive.folder.vue     |  12 +-
 packages/frontend/src/components/MkDrive.vue       |   8 +-
 packages/frontend/src/components/MkEmojiPicker.vue |  22 +-
 .../src/components/MkEmojiPickerDialog.vue         |   8 +-
 .../frontend/src/components/MkFoldableSection.vue  |  10 +-
 packages/frontend/src/components/MkFolder.vue      |  10 +-
 .../frontend/src/components/MkFollowButton.vue     |   9 +-
 .../src/components/MkGalleryPostPreview.vue        |   4 +-
 packages/frontend/src/components/MkHeatmap.vue     |   4 +-
 .../frontend/src/components/MkHorizontalSwipe.vue  |  11 +-
 .../frontend/src/components/MkImgWithBlurhash.vue  |  16 +-
 packages/frontend/src/components/MkLaunchPad.vue   |   4 +-
 packages/frontend/src/components/MkMediaAudio.vue  |  18 +-
 packages/frontend/src/components/MkMediaBanner.vue |   4 +-
 packages/frontend/src/components/MkMediaImage.vue  |  20 +-
 packages/frontend/src/components/MkMediaList.vue   |  12 +-
 packages/frontend/src/components/MkMediaVideo.vue  |  18 +-
 packages/frontend/src/components/MkMention.vue     |   4 +-
 packages/frontend/src/components/MkModal.vue       |   8 +-
 packages/frontend/src/components/MkNote.vue        |  29 +-
 .../frontend/src/components/MkNoteDetailed.vue     |  23 +-
 packages/frontend/src/components/MkNoteHeader.vue  |   1 -
 .../frontend/src/components/MkNoteMediaGrid.vue    |  18 +-
 .../frontend/src/components/MkNotifications.vue    |   8 +-
 packages/frontend/src/components/MkPagination.vue  |  17 +-
 packages/frontend/src/components/MkPostForm.vue    |  44 +-
 .../frontend/src/components/MkPostFormAttaches.vue |   4 +-
 .../src/components/MkPreferenceContainer.vue       |  94 +++++
 .../src/components/MkReactionsViewer.reaction.vue  |  10 +-
 .../frontend/src/components/MkReactionsViewer.vue  |  12 +-
 .../frontend/src/components/MkRetentionHeatmap.vue |   4 +-
 .../src/components/MkRetentionLineChart.vue        |   4 +-
 packages/frontend/src/components/MkTimeline.vue    |   8 +-
 packages/frontend/src/components/MkToast.vue       |  10 +-
 packages/frontend/src/components/MkTooltip.vue     |  10 +-
 packages/frontend/src/components/MkUrlPreview.vue  |   7 +-
 .../frontend/src/components/MkUrlPreviewPopup.vue  |   4 +-
 packages/frontend/src/components/MkUserInfo.vue    |   4 +-
 packages/frontend/src/components/MkUserPopup.vue   |  12 +-
 .../frontend/src/components/MkUserSelectDialog.vue |   8 +-
 .../frontend/src/components/MkUserSetupDialog.vue  |  12 +-
 .../MkVisitorDashboard.ActiveUsersChart.vue        |   4 +-
 packages/frontend/src/components/MkWindow.vue      |  10 +-
 .../frontend/src/components/MkYouTubePlayer.vue    |   6 +-
 packages/frontend/src/components/global/MkAd.vue   |  11 +-
 .../frontend/src/components/global/MkAvatar.vue    |  13 +-
 .../src/components/global/MkCustomEmoji.vue        |   4 +-
 .../frontend/src/components/global/MkEmoji.vue     |   8 +-
 .../frontend/src/components/global/MkError.vue     |   4 +-
 .../src/components/global/MkFooterSpacer.vue       |   4 +-
 packages/frontend/src/components/global/MkMfm.ts   |  20 +-
 .../src/components/global/MkPageHeader.tabs.vue    |  22 +-
 .../frontend/src/components/global/RouterView.vue  |   4 +-
 .../src/components/global/SearchMarker.vue         |   2 +-
 packages/frontend/src/deck.ts                      | 306 ++++++++++++++
 packages/frontend/src/directives/click-anime.ts    |   4 +-
 packages/frontend/src/directives/ripple.ts         |   4 +-
 packages/frontend/src/local-storage.ts             |   7 +-
 packages/frontend/src/os.ts                        |  10 +-
 packages/frontend/src/pages/_error_.vue            |   6 +-
 packages/frontend/src/pages/about-misskey.vue      |   4 +-
 packages/frontend/src/pages/admin/abuses.vue       |   6 +-
 .../admin/custom-emojis-manager.local.register.vue |  16 +-
 .../src/pages/admin/overview.active-users.vue      |   4 +-
 .../src/pages/admin/overview.ap-requests.vue       |   4 +-
 .../src/pages/admin/overview.instances.vue         |   6 +-
 .../src/pages/admin/overview.moderators.vue        |   6 +-
 .../src/pages/admin/overview.queue.chart.vue       |   4 +-
 .../frontend/src/pages/admin/overview.stats.vue    |   4 +-
 .../frontend/src/pages/admin/overview.users.vue    |   6 +-
 .../frontend/src/pages/admin/queue.chart.chart.vue |   4 +-
 packages/frontend/src/pages/announcement.vue       |  10 +-
 packages/frontend/src/pages/channel.vue            |   8 +-
 .../frontend/src/pages/drop-and-fusion.game.vue    |  28 +-
 packages/frontend/src/pages/flash/flash.vue        |   6 +-
 packages/frontend/src/pages/gallery/post.vue       |   8 +-
 packages/frontend/src/pages/install-extensions.vue |   8 +-
 packages/frontend/src/pages/my-lists/list.vue      |   4 +-
 packages/frontend/src/pages/note.vue               |   4 +-
 packages/frontend/src/pages/page.vue               |  15 +-
 .../frontend/src/pages/settings/accessibility.vue  |  79 ++--
 .../frontend/src/pages/settings/appearance.vue     | 231 +++++-----
 packages/frontend/src/pages/settings/deck.vue      |  10 +-
 packages/frontend/src/pages/settings/drive.vue     |  37 +-
 .../frontend/src/pages/settings/emoji-picker.vue   |  94 +++--
 .../frontend/src/pages/settings/import-export.vue  |   4 +-
 packages/frontend/src/pages/settings/index.vue     |  20 +-
 .../frontend/src/pages/settings/mute-block.vue     |   4 +-
 packages/frontend/src/pages/settings/navbar.vue    |  14 +-
 packages/frontend/src/pages/settings/other.vue     |  13 +-
 .../frontend/src/pages/settings/plugin.install.vue |   2 +-
 packages/frontend/src/pages/settings/plugin.vue    | 164 ++++----
 .../src/pages/settings/preferences-backups.vue     | 465 ---------------------
 .../frontend/src/pages/settings/preferences.vue    | 275 +++++++-----
 packages/frontend/src/pages/settings/privacy.vue   |  65 +--
 packages/frontend/src/pages/settings/profile.vue   |   4 +-
 packages/frontend/src/pages/settings/sounds.vue    |  50 ++-
 .../src/pages/settings/statusbar.statusbar.vue     |  12 +-
 packages/frontend/src/pages/settings/statusbar.vue |   8 +-
 .../frontend/src/pages/settings/theme.install.vue  |   2 +-
 packages/frontend/src/pages/settings/theme.vue     |  32 +-
 packages/frontend/src/pages/tag.vue                |  10 +-
 packages/frontend/src/pages/theme-editor.vue       |  15 +-
 packages/frontend/src/pages/timeline.vue           |  45 +-
 .../frontend/src/pages/user/activity.following.vue |   4 +-
 .../frontend/src/pages/user/activity.notes.vue     |   4 +-
 packages/frontend/src/pages/user/activity.pv.vue   |   4 +-
 packages/frontend/src/pages/user/home.vue          |   4 +-
 packages/frontend/src/pizzax.ts                    |   4 +-
 packages/frontend/src/plugin.ts                    | 223 ++++++++--
 packages/frontend/src/preferences.ts               |  81 ++++
 packages/frontend/src/preferences/def.ts           | 308 ++++++++++++++
 packages/frontend/src/preferences/profile.ts       | 236 +++++++++++
 packages/frontend/src/preferences/store.ts         |  92 ++++
 packages/frontend/src/preferences/utility.ts       | 222 ++++++++++
 packages/frontend/src/router/definition.ts         |   4 -
 .../src/scripts/autogen/settings-search-index.ts   | 141 ++++---
 packages/frontend/src/scripts/code-highlighter.ts  |  32 +-
 packages/frontend/src/scripts/emoji-picker.ts      |   4 +-
 .../frontend/src/scripts/get-drive-file-menu.ts    |   6 +-
 packages/frontend/src/scripts/get-note-menu.ts     |  15 +-
 packages/frontend/src/scripts/get-user-menu.ts     |  13 +-
 packages/frontend/src/scripts/init-chart.ts        |   4 +-
 packages/frontend/src/scripts/install-plugin.ts    | 131 ------
 packages/frontend/src/scripts/install-theme.ts     |  38 --
 packages/frontend/src/scripts/reaction-picker.ts   |   4 +-
 packages/frontend/src/scripts/select-file.ts       |  10 +-
 packages/frontend/src/scripts/sound.ts             |  23 +-
 packages/frontend/src/scripts/theme.ts             |  32 ++
 packages/frontend/src/scripts/upload.ts            |   8 +-
 packages/frontend/src/store.ts                     | 300 ++++++-------
 packages/frontend/src/theme-store.ts               |  33 +-
 .../frontend/src/ui/_common_/PreferenceRestore.vue |  73 ++++
 packages/frontend/src/ui/_common_/common.vue       |  24 +-
 .../frontend/src/ui/_common_/navbar-for-mobile.vue |   4 +-
 packages/frontend/src/ui/_common_/navbar.vue       |  11 +-
 packages/frontend/src/ui/_common_/statusbars.vue   |   4 +-
 .../frontend/src/ui/_common_/stream-indicator.vue  |   4 +-
 packages/frontend/src/ui/classic.header.vue        |   7 +-
 packages/frontend/src/ui/classic.sidebar.vue       |   9 +-
 packages/frontend/src/ui/classic.vue               |  27 +-
 packages/frontend/src/ui/deck.vue                  |  54 +--
 packages/frontend/src/ui/deck/antenna-column.vue   |  10 +-
 packages/frontend/src/ui/deck/channel-column.vue   |   8 +-
 packages/frontend/src/ui/deck/column.vue           |   6 +-
 packages/frontend/src/ui/deck/deck-store.ts        | 318 +-------------
 packages/frontend/src/ui/deck/direct-column.vue    |   2 +-
 packages/frontend/src/ui/deck/list-column.vue      |  10 +-
 packages/frontend/src/ui/deck/main-column.vue      |  12 +-
 packages/frontend/src/ui/deck/mentions-column.vue  |   2 +-
 .../frontend/src/ui/deck/notifications-column.vue  |   4 +-
 .../frontend/src/ui/deck/role-timeline-column.vue  |   8 +-
 packages/frontend/src/ui/deck/tl-column.vue        |   7 +-
 .../frontend/src/ui/deck/tl-note-notification.ts   |   2 +-
 packages/frontend/src/ui/deck/widgets-column.vue   |   4 +-
 packages/frontend/src/ui/universal.vue             |  44 +-
 packages/frontend/src/ui/universal.widgets.vue     |  26 +-
 packages/frontend/src/ui/visitor.vue               |  11 +-
 packages/frontend/src/widgets/WidgetFederation.vue |   6 +-
 packages/frontend/src/widgets/WidgetJobQueue.vue   |   4 +-
 packages/frontend/src/widgets/WidgetMemo.vue       |   8 +-
 packages/frontend/src/widgets/WidgetPhotos.vue     |   4 +-
 packages/frontend/src/widgets/WidgetTrends.vue     |   6 +-
 packages/frontend/test/emoji.test.ts               |   6 +-
 packages/frontend/test/init.ts                     |  10 +-
 181 files changed, 3441 insertions(+), 2467 deletions(-)
 create mode 100644 packages/frontend/src/components/MkPreferenceContainer.vue
 create mode 100644 packages/frontend/src/deck.ts
 delete mode 100644 packages/frontend/src/pages/settings/preferences-backups.vue
 create mode 100644 packages/frontend/src/preferences.ts
 create mode 100644 packages/frontend/src/preferences/def.ts
 create mode 100644 packages/frontend/src/preferences/profile.ts
 create mode 100644 packages/frontend/src/preferences/store.ts
 create mode 100644 packages/frontend/src/preferences/utility.ts
 delete mode 100644 packages/frontend/src/scripts/install-plugin.ts
 delete mode 100644 packages/frontend/src/scripts/install-theme.ts
 create mode 100644 packages/frontend/src/ui/_common_/PreferenceRestore.vue

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77c3c8b519..b651a03ab0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,8 @@
 -
 
 ### Client
--
+- Feat: 設定の管理が強化されました
+  - 自動でバックアップされるように
 
 ### Server
 -
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 6810d204cb..344c3ccf9d 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5278,6 +5278,86 @@ export interface Locale extends ILocale {
      * アクセシビリティ
      */
     "accessibility": string;
+    /**
+     * 設定のプロファイル
+     */
+    "preferencesProfile": string;
+    /**
+     * 設定IDをコピー
+     */
+    "copyPreferenceId": string;
+    /**
+     * 初期値に戻す
+     */
+    "resetToDefaultValue": string;
+    /**
+     * アカウントで上書き
+     */
+    "overrideByAccount": string;
+    /**
+     * 無題
+     */
+    "untitled": string;
+    /**
+     * 名前はありません
+     */
+    "noName": string;
+    /**
+     * スキップ
+     */
+    "skip": string;
+    /**
+     * 復元
+     */
+    "restore": string;
+    "_preferencesProfile": {
+        /**
+         * プロファイル名
+         */
+        "profileName": string;
+        /**
+         * このデバイスを識別する名前を設定してください。
+         */
+        "profileNameDescription": string;
+        /**
+         * 例: 「メインPC」、「スマホ」など
+         */
+        "profileNameDescription2": string;
+    };
+    "_preferencesBackup": {
+        /**
+         * 自動バックアップ
+         */
+        "autoBackup": string;
+        /**
+         * バックアップから復元
+         */
+        "restoreFromBackup": string;
+        /**
+         * バックアップが見つかりませんでした
+         */
+        "noBackupsFoundTitle": string;
+        /**
+         * 自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。
+         */
+        "noBackupsFoundDescription": string;
+        /**
+         * 復元するバックアップを選択してください
+         */
+        "selectBackupToRestore": string;
+        /**
+         * 自動バックアップを有効にするにはプロファイル名の設定が必要です。
+         */
+        "youNeedToNameYourProfileToEnableAutoBackup": string;
+        /**
+         * このデバイスで設定の自動バックアップは有効になっていません。
+         */
+        "autoPreferencesBackupIsNotEnabledForThisDevice": string;
+        /**
+         * 設定のバックアップが見つかりました
+         */
+        "backupFound": string;
+    };
     "_accountSettings": {
         /**
          * コンテンツの表示にログインを必須にする
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 7a5d2f795e..301b68e21d 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1315,6 +1315,29 @@ markAsSensitiveConfirm: "このメディアをセンシティブとして設定
 unmarkAsSensitiveConfirm: "このメディアのセンシティブ指定を解除しますか？"
 preferences: "環境設定"
 accessibility: "アクセシビリティ"
+preferencesProfile: "設定のプロファイル"
+copyPreferenceId: "設定IDをコピー"
+resetToDefaultValue: "初期値に戻す"
+overrideByAccount: "アカウントで上書き"
+untitled: "無題"
+noName: "名前はありません"
+skip: "スキップ"
+restore: "復元"
+
+_preferencesProfile:
+  profileName: "プロファイル名"
+  profileNameDescription: "このデバイスを識別する名前を設定してください。"
+  profileNameDescription2: "例: 「メインPC」、「スマホ」など"
+
+_preferencesBackup:
+  autoBackup: "自動バックアップ"
+  restoreFromBackup: "バックアップから復元"
+  noBackupsFoundTitle: "バックアップが見つかりませんでした"
+  noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
+  selectBackupToRestore: "復元するバックアップを選択してください"
+  youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
+  autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
+  backupFound: "設定のバックアップが見つかりました"
 
 _accountSettings:
   requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index d000a28232..00639be642 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -68,9 +68,9 @@ queueMicrotask(() => {
 		import('../src/directives'),
 		import('../src/widgets'),
 		import('../src/scripts/theme'),
-		import('../src/store'),
+		import('../src/preferences'),
 		import('../src/os'),
-	]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
+	]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => {
 		setup((app) => {
 			moduleInitialized = true;
 			if (app[appInitialized]) {
@@ -83,7 +83,7 @@ queueMicrotask(() => {
 			widgets(app);
 			misskeyOS = os;
 			if (isChromatic()) {
-				defaultStore.set('animation', false);
+				prefer.set('animation', false);
 			}
 		});
 	});
@@ -104,9 +104,9 @@ const preview = {
 							}
 						}).catch(() => {})
 					: Promise.resolve();
-				const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
+				const resetDefaultStorePromise = import('../src/store').then(({ store }) => {
 					// @ts-expect-error
-					defaultStore.init();
+					store.init();
 				}).catch(() => {});
 				Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
 					initLocalStorage();
diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
index 5d8cf05fff..884ae3afac 100644
--- a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
+++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
@@ -58,7 +58,7 @@ describe(normalizeClass.name, () => {
 
 it('Composition API (standard)', () => {
 	const ast = parse(`
-import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
+import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
 import { M as MkContainer } from './MkContainer-!~{03M}~.js';
 import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
 import './photoswipe-!~{003}~.js';
@@ -74,7 +74,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
     let fetching = ref(true);
     let images = ref([]);
     function thumbnail(image) {
-      return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+      return store.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
     }
     onMounted(() => {
       const image = [
@@ -173,7 +173,7 @@ export { index_photos as default };
 `.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
 	unwindCssModuleClassName(ast);
 	expect(generate(ast)).toBe(`
-import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
+import {c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
 import {M as MkContainer} from './MkContainer-!~{03M}~.js';
 import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js';
 import './photoswipe-!~{003}~.js';
@@ -190,7 +190,7 @@ const index_photos = defineComponent({
     let fetching = ref(true);
     let images = ref([]);
     function thumbnail(image) {
-      return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+      return store.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
     }
     onMounted(() => {
       const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"];
@@ -268,7 +268,7 @@ export {index_photos as default};
 it('Composition API (with `useCssModule()`)', () => {
 	const ast = parse(`
 import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js';
-import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
+import { d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
 
 function isDebuggerEnabled(id) {
   try {
@@ -393,7 +393,7 @@ const _sfc_main = defineComponent({
       el.style.left = "";
     }
     return () => h(
-      defaultStore.state.animation ? TransitionGroup : "div",
+      prefer.s.animation ? TransitionGroup : "div",
       {
         class: {
           [$style["date-separated-list"]]: true,
@@ -402,7 +402,7 @@ const _sfc_main = defineComponent({
           [$style["direction-down"]]: props.direction === "down",
           [$style["direction-up"]]: props.direction === "up"
         },
-        ...defaultStore.state.animation ? {
+        ...prefer.s.animation ? {
           name: "list",
           tag: "div",
           onBeforeLeave,
@@ -441,7 +441,7 @@ export { MkDateSeparatedList as M };
 	unwindCssModuleClassName(ast);
 	expect(generate(ast)).toBe(`
 import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js';
-import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
+import {d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
 function isDebuggerEnabled(id) {
   try {
     return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
@@ -555,7 +555,7 @@ const _sfc_main = defineComponent({
       el.style.top = "";
       el.style.left = "";
     }
-    return () => h(defaultStore.state.animation ? TransitionGroup : "div", {
+    return () => h(prefer.s.animation ? TransitionGroup : "div", {
       class: {
         [$style["date-separated-list"]]: true,
         [$style["date-separated-list-nogap"]]: props.noGap,
@@ -563,7 +563,7 @@ const _sfc_main = defineComponent({
         [$style["direction-down"]]: props.direction === "down",
         [$style["direction-up"]]: props.direction === "up"
       },
-      ...defaultStore.state.animation ? {
+      ...prefer.s.animation ? {
         name: "list",
         tag: "div",
         onBeforeLeave,
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index d09b98efe0..7b35d12a80 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -6,6 +6,8 @@
 import { computed, watch, version as vueVersion } from 'vue';
 import { compareVersions } from 'compare-versions';
 import { version, lang, updateLocale, locale } from '@@/js/config.js';
+import defaultLightTheme from '@@/themes/l-light.json5';
+import defaultDarkTheme from '@@/themes/d-green-lime.json5';
 import type { App } from 'vue';
 import widgets from '@/widgets/index.js';
 import directives from '@/directives/index.js';
@@ -14,7 +16,7 @@ import { applyTheme } from '@/scripts/theme.js';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
 import { updateI18n, i18n } from '@/i18n.js';
 import { $i, refreshAccount, login } from '@/account.js';
-import { defaultStore, ColdDeviceStorage } from '@/store.js';
+import { store } from '@/store.js';
 import { fetchInstance, instance } from '@/instance.js';
 import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js';
 import { reloadChannel } from '@/scripts/unison-reload.js';
@@ -26,6 +28,7 @@ import { miLocalStorage } from '@/local-storage.js';
 import { fetchCustomEmojis } from '@/custom-emojis.js';
 import { setupRouter } from '@/router/main.js';
 import { createMainRouter } from '@/router/definition.js';
+import { prefer } from '@/preferences.js';
 
 export async function common(createVue: () => App<Element>) {
 	console.info(`Misskey v${version}`);
@@ -38,7 +41,7 @@ export async function common(createVue: () => App<Element>) {
 		// eslint-disable-next-line @typescript-eslint/no-explicit-any
 		(window as any).$i = $i;
 		// eslint-disable-next-line @typescript-eslint/no-explicit-any
-		(window as any).$store = defaultStore;
+		(window as any).$store = store;
 
 		window.addEventListener('error', event => {
 			console.error(event);
@@ -123,7 +126,7 @@ export async function common(createVue: () => App<Element>) {
 	html.setAttribute('lang', lang);
 	//#endregion
 
-	await defaultStore.ready;
+	await store.ready;
 	await deckStore.ready;
 
 	const fetchInstanceMetaPromise = fetchInstance();
@@ -151,56 +154,63 @@ export async function common(createVue: () => App<Element>) {
 	//#endregion
 
 	// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
-	watch(defaultStore.reactiveState.darkMode, (darkMode) => {
-		applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
+	watch(store.reactiveState.darkMode, (darkMode) => {
+		applyTheme(darkMode
+			? (prefer.s.darkTheme ?? defaultDarkTheme)
+			: (prefer.s.lightTheme ?? defaultLightTheme),
+		);
 	}, { immediate: miLocalStorage.getItem('theme') == null });
 
-	document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light';
+	document.documentElement.dataset.colorScheme = store.state.darkMode ? 'dark' : 'light';
 
-	const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
-	const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
+	const darkTheme = prefer.model('darkTheme');
+	const lightTheme = prefer.model('lightTheme');
 
 	watch(darkTheme, (theme) => {
-		if (defaultStore.state.darkMode) {
-			applyTheme(theme);
+		if (store.state.darkMode) {
+			applyTheme(theme ?? defaultDarkTheme);
 		}
 	});
 
 	watch(lightTheme, (theme) => {
-		if (!defaultStore.state.darkMode) {
-			applyTheme(theme);
+		if (!store.state.darkMode) {
+			applyTheme(theme ?? defaultLightTheme);
 		}
 	});
 
 	//#region Sync dark mode
-	if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
-		defaultStore.set('darkMode', isDeviceDarkmode());
+	if (prefer.s.syncDeviceDarkMode) {
+		store.set('darkMode', isDeviceDarkmode());
 	}
 
 	window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
-		if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
-			defaultStore.set('darkMode', mql.matches);
+		if (prefer.s.syncDeviceDarkMode) {
+			store.set('darkMode', mql.matches);
 		}
 	});
 	//#endregion
 
+	if (prefer.s.darkTheme && store.state.darkMode) {
+		if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
+	} else if (prefer.s.lightTheme && !store.state.darkMode) {
+		if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme);
+	}
+
 	fetchInstanceMetaPromise.then(() => {
-		if (defaultStore.state.themeInitial) {
-			if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
-			if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
-			defaultStore.set('themeInitial', false);
-		}
+		// TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア
+		if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.set('lightTheme', JSON.parse(instance.defaultLightTheme));
+		if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
 	});
 
-	watch(defaultStore.reactiveState.overridedDeviceKind, (kind) => {
+	watch(store.reactiveState.overridedDeviceKind, (kind) => {
 		updateDeviceKind(kind);
 	}, { immediate: true });
 
-	watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
+	watch(prefer.r.useBlurEffectForModal, v => {
 		document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
 	}, { immediate: true });
 
-	watch(defaultStore.reactiveState.useBlurEffect, v => {
+	watch(prefer.r.useBlurEffect, v => {
 		if (v) {
 			document.documentElement.style.removeProperty('--MI-blur');
 		} else {
@@ -214,7 +224,7 @@ export async function common(createVue: () => App<Element>) {
 			navigator.wakeLock.request('screen');
 		}
 	});
-	if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) {
+	if (prefer.s.keepScreenOn && 'wakeLock' in navigator) {
 		navigator.wakeLock.request('screen')
 			.then(onVisibilityChange)
 			.catch(() => {
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 3a43c6794b..0ebe55ed1d 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -5,26 +5,29 @@
 
 import { createApp, defineAsyncComponent, markRaw } from 'vue';
 import { ui } from '@@/js/config.js';
-import { common } from './common.js';
 import * as Misskey from 'misskey-js';
+import { common } from './common.js';
 import type { Component } from 'vue';
+import type { Keymap } from '@/scripts/hotkey.js';
 import { i18n } from '@/i18n.js';
 import { alert, confirm, popup, post, toast } from '@/os.js';
 import { useStream } from '@/stream.js';
 import * as sound from '@/scripts/sound.js';
 import { $i, signout, updateAccountPartial } from '@/account.js';
 import { instance } from '@/instance.js';
-import { ColdDeviceStorage, defaultStore } from '@/store.js';
+import { ColdDeviceStorage, store } from '@/store.js';
 import { reactionPicker } from '@/scripts/reaction-picker.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
 import { initializeSw } from '@/scripts/initialize-sw.js';
-import { deckStore } from '@/ui/deck/deck-store.js';
 import { emojiPicker } from '@/scripts/emoji-picker.js';
 import { mainRouter } from '@/router/main.js';
 import { makeHotkey } from '@/scripts/hotkey.js';
-import type { Keymap } from '@/scripts/hotkey.js';
 import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
+import { prefer } from '@/preferences.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { deckStore } from '@/ui/deck/deck-store.js';
+import { launchPlugin } from '@/plugin.js';
 
 export async function mainBoot() {
 	const { isClientUpdated } = await common(() => {
@@ -34,7 +37,7 @@ export async function mainBoot() {
 		if (!$i) uiStyle = 'visitor';
 
 		if (searchParams.has('zen')) uiStyle = 'zen';
-		if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen';
+		if (uiStyle === 'deck' && prefer.s['deck.useSimpleUiForNonRootPages'] && location.pathname !== '/') uiStyle = 'zen';
 
 		if (searchParams.has('ui')) uiStyle = searchParams.get('ui');
 
@@ -73,9 +76,9 @@ export async function mainBoot() {
 
 	let reloadDialogShowing = false;
 	stream.on('_disconnected_', async () => {
-		if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
+		if (prefer.s.serverDisconnectedBehavior === 'reload') {
 			location.reload();
-		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
+		} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
 			if (reloadDialogShowing) return;
 			reloadDialogShowing = true;
 			const { canceled } = await confirm({
@@ -102,18 +105,14 @@ export async function mainBoot() {
 		removeCustomEmojis(emojiData.emojis);
 	});
 
-	for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
-		import('@/plugin.js').then(async ({ install }) => {
-			// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
-			await new Promise(r => setTimeout(r, 0));
-			install(plugin);
-		});
+	for (const plugin of prefer.s.plugins.filter(p => p.active)) {
+		launchPlugin(plugin);
 	}
 
 	try {
-		if (defaultStore.state.enableSeasonalScreenEffect) {
+		if (prefer.s.enableSeasonalScreenEffect) {
 			const month = new Date().getMonth() + 1;
-			if (defaultStore.state.hemisphere === 'S') {
+			if (prefer.s.hemisphere === 'S') {
 				// ▼南半球
 				if (month === 7 || month === 8) {
 					const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
@@ -138,8 +137,99 @@ export async function mainBoot() {
 	}
 
 	if ($i) {
-		defaultStore.loaded.then(() => {
-			if (defaultStore.state.accountSetupWizard !== -1) {
+		store.loaded.then(async () => {
+			// prefereces migration
+			// TODO: そのうち消す
+			if (store.state.menu.length > 0) {
+				const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []);
+				if (themes.length > 0) {
+					prefer.set('themes', themes);
+				}
+				const plugins = ColdDeviceStorage.get('plugins');
+				prefer.set('plugins', plugins.map(p => ({
+					...p,
+					installId: (p as any).id,
+					id: undefined,
+				})));
+				prefer.set('lightTheme', ColdDeviceStorage.get('lightTheme'));
+				prefer.set('darkTheme', ColdDeviceStorage.get('darkTheme'));
+				prefer.set('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode'));
+				prefer.set('keepCw', store.state.keepCw);
+				prefer.set('collapseRenotes', store.state.collapseRenotes);
+				prefer.set('rememberNoteVisibility', store.state.rememberNoteVisibility);
+				prefer.set('uploadFolder', store.state.uploadFolder);
+				prefer.set('keepOriginalUploading', store.state.keepOriginalUploading);
+				prefer.set('menu', store.state.menu);
+				prefer.set('statusbars', store.state.statusbars);
+				prefer.set('pinnedUserLists', store.state.pinnedUserLists);
+				prefer.set('serverDisconnectedBehavior', store.state.serverDisconnectedBehavior);
+				prefer.set('nsfw', store.state.nsfw);
+				prefer.set('highlightSensitiveMedia', store.state.highlightSensitiveMedia);
+				prefer.set('animation', store.state.animation);
+				prefer.set('animatedMfm', store.state.animatedMfm);
+				prefer.set('advancedMfm', store.state.advancedMfm);
+				prefer.set('showReactionsCount', store.state.showReactionsCount);
+				prefer.set('enableQuickAddMfmFunction', store.state.enableQuickAddMfmFunction);
+				prefer.set('loadRawImages', store.state.loadRawImages);
+				prefer.set('imageNewTab', store.state.imageNewTab);
+				prefer.set('disableShowingAnimatedImages', store.state.disableShowingAnimatedImages);
+				prefer.set('emojiStyle', store.state.emojiStyle);
+				prefer.set('menuStyle', store.state.menuStyle);
+				prefer.set('useBlurEffectForModal', store.state.useBlurEffectForModal);
+				prefer.set('useBlurEffect', store.state.useBlurEffect);
+				prefer.set('showFixedPostForm', store.state.showFixedPostForm);
+				prefer.set('showFixedPostFormInChannel', store.state.showFixedPostFormInChannel);
+				prefer.set('enableInfiniteScroll', store.state.enableInfiniteScroll);
+				prefer.set('useReactionPickerForContextMenu', store.state.useReactionPickerForContextMenu);
+				prefer.set('showGapBetweenNotesInTimeline', store.state.showGapBetweenNotesInTimeline);
+				prefer.set('instanceTicker', store.state.instanceTicker);
+				prefer.set('emojiPickerScale', store.state.emojiPickerScale);
+				prefer.set('emojiPickerWidth', store.state.emojiPickerWidth);
+				prefer.set('emojiPickerHeight', store.state.emojiPickerHeight);
+				prefer.set('emojiPickerStyle', store.state.emojiPickerStyle);
+				prefer.set('reportError', store.state.reportError);
+				prefer.set('squareAvatars', store.state.squareAvatars);
+				prefer.set('showAvatarDecorations', store.state.showAvatarDecorations);
+				prefer.set('numberOfPageCache', store.state.numberOfPageCache);
+				prefer.set('showNoteActionsOnlyHover', store.state.showNoteActionsOnlyHover);
+				prefer.set('showClipButtonInNoteFooter', store.state.showClipButtonInNoteFooter);
+				prefer.set('reactionsDisplaySize', store.state.reactionsDisplaySize);
+				prefer.set('limitWidthOfReaction', store.state.limitWidthOfReaction);
+				prefer.set('forceShowAds', store.state.forceShowAds);
+				prefer.set('aiChanMode', store.state.aiChanMode);
+				prefer.set('devMode', store.state.devMode);
+				prefer.set('mediaListWithOneImageAppearance', store.state.mediaListWithOneImageAppearance);
+				prefer.set('notificationPosition', store.state.notificationPosition);
+				prefer.set('notificationStackAxis', store.state.notificationStackAxis);
+				prefer.set('enableCondensedLine', store.state.enableCondensedLine);
+				prefer.set('keepScreenOn', store.state.keepScreenOn);
+				prefer.set('disableStreamingTimeline', store.state.disableStreamingTimeline);
+				prefer.set('useGroupedNotifications', store.state.useGroupedNotifications);
+				prefer.set('dataSaver', store.state.dataSaver);
+				prefer.set('enableSeasonalScreenEffect', store.state.enableSeasonalScreenEffect);
+				prefer.set('enableHorizontalSwipe', store.state.enableHorizontalSwipe);
+				prefer.set('useNativeUiForVideoAudioPlayer', store.state.useNativeUIForVideoAudioPlayer);
+				prefer.set('keepOriginalFilename', store.state.keepOriginalFilename);
+				prefer.set('alwaysConfirmFollow', store.state.alwaysConfirmFollow);
+				prefer.set('confirmWhenRevealingSensitiveMedia', store.state.confirmWhenRevealingSensitiveMedia);
+				prefer.set('contextMenu', store.state.contextMenu);
+				prefer.set('skipNoteRender', store.state.skipNoteRender);
+				prefer.set('showSoftWordMutedWord', store.state.showSoftWordMutedWord);
+				prefer.set('confirmOnReact', store.state.confirmOnReact);
+				prefer.set('sound.masterVolume', store.state.sound_masterVolume);
+				prefer.set('sound.notUseSound', store.state.sound_notUseSound);
+				prefer.set('sound.useSoundOnlyWhenActive', store.state.sound_useSoundOnlyWhenActive);
+				prefer.set('sound.on.note', store.state.sound_note as any);
+				prefer.set('sound.on.noteMy', store.state.sound_noteMy as any);
+				prefer.set('sound.on.notification', store.state.sound_notification as any);
+				prefer.set('sound.on.reaction', store.state.sound_reaction as any);
+				store.set('deck.profile', deckStore.state.profile);
+				store.set('deck.columns', deckStore.state.columns);
+				store.set('deck.layout', deckStore.state.layout);
+				store.set('menu', []);
+			}
+
+			if (store.state.accountSetupWizard !== -1) {
 				const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
 					closed: () => dispose(),
 				});
@@ -154,7 +244,7 @@ export async function mainBoot() {
 			});
 		}
 
-		function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) {
+		function onAnnouncementCreated(ev: { announcement: Misskey.entities.Announcement }) {
 			const announcement = ev.announcement;
 			if (announcement.display === 'dialog') {
 				const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
@@ -412,7 +502,7 @@ export async function mainBoot() {
 			post();
 		},
 		'd': () => {
-			defaultStore.set('darkMode', !defaultStore.state.darkMode);
+			store.set('darkMode', !store.state.darkMode);
 		},
 		's': () => {
 			mainRouter.push('/search');
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 33495c8af6..1a68353331 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -54,17 +54,18 @@ import contains from '@/scripts/contains.js';
 import { acct } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
 import { searchEmoji } from '@/scripts/search-emoji.js';
+import { prefer } from '@/preferences.js';
 
 const lib = emojilist.filter(x => x.category !== 'flags');
 
 const emojiDb = computed(() => {
 	//#region Unicode Emoji
-	const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
+	const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
 
 	const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
 		emoji: x.char,
@@ -72,7 +73,7 @@ const emojiDb = computed(() => {
 		url: char2path(x.char),
 	}));
 
-	for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+	for (const index of Object.values(store.state.additionalUnicodeEmojiIndexes)) {
 		for (const [emoji, keywords] of Object.entries(index)) {
 			for (const k of keywords) {
 				unicodeEmojiDB.push({
@@ -154,10 +155,10 @@ function complete(type: string, value: any) {
 	emit('done', { type, value });
 	emit('closed');
 	if (type === 'emoji') {
-		let recents = defaultStore.state.recentlyUsedEmojis;
+		let recents = store.state.recentlyUsedEmojis;
 		recents = recents.filter((emoji: any) => emoji !== value);
 		recents.unshift(value);
-		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
+		store.set('recentlyUsedEmojis', recents.splice(0, 32));
 	}
 }
 
@@ -237,7 +238,7 @@ function exec() {
 	} else if (props.type === 'emoji') {
 		if (!props.q || props.q === '') {
 			// 最近使った絵文字をサジェスト
-			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
+			emojis.value = store.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
 			return;
 		}
 
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 134f8226d4..05b8264a83 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 // APIs provided by Captcha services
 // see: https://docs.hcaptcha.com/configuration/#javascript-api
@@ -154,7 +154,7 @@ async function requestRender() {
 
 		captchaWidgetId.value = captcha.value.render(elem, {
 			sitekey: props.sitekey,
-			theme: defaultStore.state.darkMode ? 'dark' : 'light',
+			theme: store.state.darkMode ? 'dark' : 'light',
 			callback: callback,
 			'expired-callback': () => callback(undefined),
 			'error-callback': () => callback(undefined),
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index d05f4921f6..c8685f8618 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -54,7 +54,7 @@ import { onMounted, ref, shallowRef, watch } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApiGet } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { alpha } from '@/scripts/color.js';
@@ -161,7 +161,7 @@ const render = () => {
 		chartInstance.destroy();
 	}
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
 
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index 0d7a67eaec..a9001b376a 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -13,7 +13,7 @@ import { computed, ref, watch } from 'vue';
 import { bundledLanguagesInfo } from 'shiki/langs';
 import type { BundledLanguage } from 'shiki/langs';
 import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 const props = defineProps<{
 	code: string;
@@ -22,7 +22,7 @@ const props = defineProps<{
 }>();
 
 const highlighter = await getHighlighter();
-const darkMode = defaultStore.reactiveState.darkMode;
+const darkMode = store.reactiveState.darkMode;
 const codeLang = ref<BundledLanguage | 'aiscript'>('js');
 
 const [lightThemeName, darkThemeName] = await Promise.all([
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index cb82bfd98b..06e4d32878 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</button>
 	<Suspense>
 		<template #fallback>
-			<MkLoading />
+			<MkLoading/>
 		</template>
 		<XCode v-if="show && lang" :code="code" :lang="lang"/>
 		<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
@@ -28,9 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent, ref } from 'vue';
 import * as os from '@/os.js';
 import MkLoading from '@/components/global/MkLoading.vue';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	code: string;
@@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{
 	forceShow: false,
 });
 
-const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code);
+const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code);
 
 const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
 
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index 6a278250fa..169f3df7a4 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 	</header>
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''"
 		@enter="enter"
 		@afterEnter="afterEnter"
 		@leave="leave"
@@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index f51fefa0c0..ed9e5127b0 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <Transition
 	appear
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
 >
 	<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
 		<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
@@ -22,7 +22,7 @@ import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
 import contains from '@/scripts/contains.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import * as os from '@/os.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 0186cfc2c0..9f9c5b32e2 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -35,13 +35,13 @@ import { onMounted, shallowRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import Cropper from 'cropperjs';
 import tinycolor from 'tinycolor2';
+import { apiUrl } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import * as os from '@/os.js';
 import { $i } from '@/account.js';
-import { defaultStore } from '@/store.js';
-import { apiUrl } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
+import { prefer } from '@/preferences.js';
 
 const emit = defineEmits<{
 	(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
@@ -81,8 +81,8 @@ const ok = async () => {
 			formData.append('i', $i!.token);
 			if (props.uploadFolder) {
 				formData.append('folderId', props.uploadFolder);
-			} else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) {
-				formData.append('folderId', defaultStore.state.uploadFolder);
+			} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
+				formData.append('folderId', prefer.s.uploadFolder);
 			}
 
 			window.fetch(apiUrl + '/drive/files/create', {
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index 0d5a16126b..b5842876ac 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts">
 import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
 import type { PropType } from 'vue';
+import type { MisskeyEntity } from '@/types/date-separated-list.js';
 import MkAd from '@/components/global/MkAd.vue';
 import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
-import type { MisskeyEntity } from '@/types/date-separated-list.js';
+import { prefer } from '@/preferences.js';
 
 export default defineComponent({
 	props: {
@@ -150,7 +150,7 @@ export default defineComponent({
 			[$style['direction-up']]: props.direction === 'up',
 		};
 
-		return () => defaultStore.state.animation ? h(TransitionGroup, {
+		return () => prefer.s.animation ? h(TransitionGroup, {
 			class: classes,
 			name: 'list',
 			tag: 'div',
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 44e3b59ade..f91b248d0c 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
 		{{ folder.name }}
 	</p>
-	<p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload">
+	<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
 		{{ i18n.ts.uploadFolder }}
 	</p>
 	<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
@@ -40,9 +40,9 @@ import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	folder: Misskey.entities.DriveFolder;
@@ -244,8 +244,8 @@ function deleteFolder() {
 	misskeyApi('drive/folders/delete', {
 		folderId: props.folder.id,
 	}).then(() => {
-		if (defaultStore.state.uploadFolder === props.folder.id) {
-			defaultStore.set('uploadFolder', null);
+		if (prefer.s.uploadFolder === props.folder.id) {
+			prefer.set('uploadFolder', null);
 		}
 	}).catch(err => {
 		switch (err.id) {
@@ -266,7 +266,7 @@ function deleteFolder() {
 }
 
 function setAsUploadFolder() {
-	defaultStore.set('uploadFolder', props.folder.id);
+	prefer.set('uploadFolder', props.folder.id);
 }
 
 function onContextmenu(ev: MouseEvent) {
@@ -295,7 +295,7 @@ function onContextmenu(ev: MouseEvent) {
 		danger: true,
 		action: deleteFolder,
 	}];
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menu = menu.concat([{ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFolderId,
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 8be6d6f53d..6863e65b05 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -106,10 +106,10 @@ import XFile from '@/components/MkDrive.file.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { useStream } from '@/stream.js';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { uploadFile, uploads } from '@/scripts/upload.js';
 import { claimAchievement } from '@/scripts/achievements.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	initialFolder?: Misskey.entities.DriveFolder;
@@ -142,7 +142,7 @@ const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
 const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
 const uploadings = uploads;
 const connection = useStream().useChannel('drive');
-const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
+const keepOriginal = ref<boolean>(prefer.s.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
 
 // ドロップされようとしているか
 const draghover = ref(false);
@@ -716,7 +716,7 @@ function onContextmenu(ev: MouseEvent) {
 }
 
 onMounted(() => {
-	if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
+	if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) {
 		nextTick(() => {
 			ilFilesObserver.observe(loadMoreFiles.value?.$el);
 		});
@@ -737,7 +737,7 @@ onMounted(() => {
 });
 
 onActivated(() => {
-	if (defaultStore.state.enableInfiniteScroll) {
+	if (prefer.s.enableInfiniteScroll) {
 		nextTick(() => {
 			ilFilesObserver.observe(loadMoreFiles.value?.$el);
 		});
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 62a1000674..3c5b3133da 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -134,14 +134,15 @@ import * as os from '@/os.js';
 import { isTouchUsing } from '@/scripts/touch.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
 import { $i } from '@/account.js';
 import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	showPinned?: boolean;
-  pinnedEmojis?: string[];
+	pinnedEmojis?: string[];
 	maxHeight?: number;
 	asDrawer?: boolean;
 	asWindow?: boolean;
@@ -163,8 +164,9 @@ const {
 	emojiPickerScale,
 	emojiPickerWidth,
 	emojiPickerHeight,
-	recentlyUsedEmojis,
-} = defaultStore.reactiveState;
+} = prefer.r;
+
+const recentlyUsedEmojis = store.reactiveState.recentlyUsedEmojis;
 
 const recentlyUsedEmojisDef = computed(() => {
 	return recentlyUsedEmojis.value.map(getDef);
@@ -317,7 +319,7 @@ watch(q, () => {
 			}
 			if (matches.size >= max) return matches;
 
-			for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+			for (const index of Object.values(store.state.additionalUnicodeEmojiIndexes)) {
 				for (const emoji of emojis) {
 					if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) {
 						matches.add(emoji);
@@ -334,7 +336,7 @@ watch(q, () => {
 			}
 			if (matches.size >= max) return matches;
 
-			for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+			for (const index of Object.values(store.state.additionalUnicodeEmojiIndexes)) {
 				for (const emoji of emojis) {
 					if (index[emoji.char].some(k => k.startsWith(newQ))) {
 						matches.add(emoji);
@@ -351,7 +353,7 @@ watch(q, () => {
 			}
 			if (matches.size >= max) return matches;
 
-			for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+			for (const index of Object.values(store.state.additionalUnicodeEmojiIndexes)) {
 				for (const emoji of emojis) {
 					if (index[emoji.char].some(k => k.includes(newQ))) {
 						matches.add(emoji);
@@ -413,7 +415,7 @@ function computeButtonTitle(ev: MouseEvent): void {
 
 function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) {
 	const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
-	if (el && defaultStore.state.animation) {
+	if (el && prefer.s.animation) {
 		const rect = el.getBoundingClientRect();
 		const x = rect.left + (el.offsetWidth / 2);
 		const y = rect.top + (el.offsetHeight / 2);
@@ -427,10 +429,10 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef,
 
 	// 最近使った絵文字更新
 	if (!pinned.value?.includes(key)) {
-		let recents = defaultStore.state.recentlyUsedEmojis;
+		let recents = store.state.recentlyUsedEmojis;
 		recents = recents.filter((emoji) => emoji !== key);
 		recents.unshift(key);
-		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
+		store.set('recentlyUsedEmojis', recents.splice(0, 32));
 	}
 }
 
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index 21c712b441..6d7062b41c 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	ref="modal"
 	v-slot="{ type, maxHeight }"
 	:zPriority="'middle'"
-	:preferType="defaultStore.state.emojiPickerStyle"
+	:preferType="prefer.s.emojiPickerStyle"
 	:hasInteractionWithOtherFocusTrappedEls="true"
 	:transparentBg="true"
 	:manualShowing="manualShowing"
@@ -40,16 +40,16 @@ import * as Misskey from 'misskey-js';
 import { shallowRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	manualShowing?: boolean | null;
 	src?: HTMLElement;
 	showPinned?: boolean;
-  pinnedEmojis?: string[],
+	pinnedEmojis?: string[],
 	asReactionPicker?: boolean;
 	targetNote?: Misskey.entities.Note;
-  choseAndClose?: boolean;
+	choseAndClose?: boolean;
 }>(), {
 	manualShowing: null,
 	showPinned: true,
diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue
index fb1b5220fb..08b8d60d09 100644
--- a/packages/frontend/src/components/MkFoldableSection.vue
+++ b/packages/frontend/src/components/MkFoldableSection.vue
@@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</button>
 	</header>
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.folderToggleEnterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.folderToggleLeaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.folderToggleEnterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.folderToggleLeaveTo : ''"
 		@enter="enter"
 		@afterEnter="afterEnter"
 		@leave="leave"
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref, shallowRef, watch } from 'vue';
 import { miLocalStorage } from '@/local-storage.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/scripts/get-bg-color.js';
 
 const miLocalStoragePrefix = 'ui:folder:' as const;
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 384c0c0b34..1f27e9e221 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -27,10 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 		<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
 			<Transition
-				:enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
-				:leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
-				:enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
-				:leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
+				:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
+				:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
+				:enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''"
+				:leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''"
 				@enter="enter"
 				@afterEnter="afterEnter"
 				@leave="leave"
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { nextTick, onMounted, ref, shallowRef } from 'vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/scripts/get-bg-color.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index c1dc67f776..bd45df843a 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -45,7 +45,8 @@ import { i18n } from '@/i18n.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import { $i } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	user: Misskey.entities.UserDetailed,
@@ -100,7 +101,7 @@ async function onClick() {
 				userId: props.user.id,
 			});
 		} else {
-			if (defaultStore.state.alwaysConfirmFollow) {
+			if (prefer.s.alwaysConfirmFollow) {
 				const { canceled } = await os.confirm({
 					type: 'question',
 					text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }),
@@ -120,11 +121,11 @@ async function onClick() {
 			} else {
 				await misskeyApi('following/create', {
 					userId: props.user.id,
-					withReplies: defaultStore.state.defaultWithReplies,
+					withReplies: store.state.defaultWithReplies,
 				});
 				emit('update:user', {
 					...props.user,
-					withReplies: defaultStore.state.defaultWithReplies,
+					withReplies: store.state.defaultWithReplies,
 				});
 				hasPendingFollowRequestFromYou.value = true;
 
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
index 22f8355acf..49a6c65170 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.vue
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -35,14 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { computed, ref } from 'vue';
 import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	post: Misskey.entities.GalleryPost;
 }>();
 
 const hover = ref(false);
-const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive);
+const safe = computed(() => prefer.s.nsfw === 'ignore' || prefer.s.nsfw === 'respect' && !props.post.isSensitive);
 const show = computed(() => safe.value || hover.value);
 
 function enterHover(): void {
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 0cc0df9911..058a2d7c78 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -17,7 +17,7 @@ import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { alpha } from '@/scripts/color.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -106,7 +106,7 @@ async function renderChart() {
 
 	await nextTick();
 
-	const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
+	const color = store.state.darkMode ? '#b4e900' : '#86b300';
 
 	// 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする
 	const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
index 196c962a06..ca597b43cb 100644
--- a/packages/frontend/src/components/MkHorizontalSwipe.vue
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -28,12 +28,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, shallowRef, computed, nextTick, watch } from 'vue';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
-import { defaultStore } from '@/store.js';
 import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js';
+import { prefer } from '@/preferences.js';
 
 const rootEl = shallowRef<HTMLDivElement>();
 
-// eslint-disable-next-line no-undef
 const tabModel = defineModel<string>('tab');
 
 const props = defineProps<{
@@ -44,7 +43,7 @@ const emit = defineEmits<{
 	(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
 }>();
 
-const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value);
+const shouldAnimate = computed(() => prefer.r.enableHorizontalSwipe.value || prefer.r.animation.value);
 
 // ▼ しきい値 ▼ //
 
@@ -72,7 +71,7 @@ const isSwipingForClass = ref(false);
 let swipeAborted = false;
 
 function touchStart(event: TouchEvent) {
-	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+	if (!prefer.r.enableHorizontalSwipe.value) return;
 
 	if (event.touches.length !== 1) return;
 
@@ -83,7 +82,7 @@ function touchStart(event: TouchEvent) {
 }
 
 function touchMove(event: TouchEvent) {
-	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+	if (!prefer.r.enableHorizontalSwipe.value) return;
 
 	if (event.touches.length !== 1) return;
 
@@ -134,7 +133,7 @@ function touchEnd(event: TouchEvent) {
 		return;
 	}
 
-	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+	if (!prefer.r.enableHorizontalSwipe.value) return;
 
 	if (event.touches.length !== 0) return;
 
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index c04d0864fb..37cbc5d06b 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''">
 	<TransitionGroup
-		:duration="defaultStore.state.animation && props.transition?.duration || undefined"
-		:enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
-		:leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined"
-		:enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
-		:leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
-		:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
-		:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
+		:duration="prefer.s.animation && props.transition?.duration || undefined"
+		:enterActiveClass="prefer.s.animation && props.transition?.enterActiveClass || undefined"
+		:leaveActiveClass="prefer.s.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined"
+		:enterFromClass="prefer.s.animation && props.transition?.enterFromClass || undefined"
+		:leaveToClass="prefer.s.animation && props.transition?.leaveToClass || undefined"
+		:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
+		:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
 	>
 		<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
 		<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
@@ -60,7 +60,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
 import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
 import { v4 as uuid } from 'uuid';
 import { render } from 'buraha';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	transition?: {
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 32c1a2d172..9538979dd1 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -30,8 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { shallowRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import { navbarItemDef } from '@/navbar.js';
-import { defaultStore } from '@/store.js';
 import { deviceKind } from '@/scripts/device-kind.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	src?: HTMLElement;
@@ -50,7 +50,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop
 
 const modal = shallowRef<InstanceType<typeof MkModal>>();
 
-const menu = defaultStore.state.menu;
+const menu = prefer.s.menu;
 
 const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
 	type: def.to ? 'link' : 'button',
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 3d2795b37a..83ce212efd 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -10,20 +10,20 @@ SPDX-License-Identifier: AGPL-3.0-only
 	tabindex="0"
 	:class="[
 		$style.audioContainer,
-		(audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
+		(audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
 	]"
 	@contextmenu.stop
 	@keydown.stop
 >
 	<button v-if="hide" :class="$style.hidden" @click="show">
 		<div :class="$style.hiddenTextWrapper">
-			<b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b>
-			<b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b>
+			<b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b>
+			<b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b>
 			<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
 		</div>
 	</button>
 
-	<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer">
+	<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer">
 		<audio
 			ref="audioEl"
 			preload="metadata"
@@ -93,13 +93,13 @@ import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import type { Keymap } from '@/scripts/hotkey.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
 import { $i, iAmModerator } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	audio: Misskey.entities.DriveFile;
@@ -155,10 +155,10 @@ const playerEl = shallowRef<HTMLDivElement>();
 const audioEl = shallowRef<HTMLAudioElement>();
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
-const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
+const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
 
 async function show() {
-	if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
+	if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
 		const { canceled } = await os.confirm({
 			type: 'question',
 			text: i18n.ts.sensitiveMediaRevealConfirm,
@@ -240,7 +240,7 @@ function showMenu(ev: MouseEvent) {
 		menu.push({ type: 'divider' }, ...details);
 	}
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menu.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFileId,
@@ -407,7 +407,7 @@ onDeactivated(() => {
 	elapsedTimeMs.value = 0;
 	durationMs.value = 0;
 	bufferedEnd.value = 0;
-	hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore');
+	hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore');
 	stopAudioElWatch();
 	onceInit = false;
 	if (mediaTickFrameId) {
diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue
index 3e521e0a03..f23cf507fb 100644
--- a/packages/frontend/src/components/MkMediaBanner.vue
+++ b/packages/frontend/src/components/MkMediaBanner.vue
@@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
 import * as os from '@/os.js';
 import MkMediaAudio from '@/components/MkMediaAudio.vue';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	media: Misskey.entities.DriveFile;
@@ -38,7 +38,7 @@ const props = defineProps<{
 const hide = ref(true);
 
 async function show() {
-	if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
+	if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
 		const { canceled } = await os.confirm({
 			type: 'question',
 			text: i18n.ts.sensitiveMediaRevealConfirm,
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 8ab990b926..8206e3d8f6 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick">
+<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick">
 	<component
 		:is="disableImageLink ? 'div' : 'a'"
 		v-bind="disableImageLink ? {
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	>
 		<ImgWithBlurhash
 			:hash="image.blurhash"
-			:src="(defaultStore.state.dataSaver.media && hide) ? null : url"
+			:src="(prefer.s.dataSaver.media && hide) ? null : url"
 			:forceBlurhash="hide"
 			:cover="hide || cover"
 			:alt="image.comment || image.name"
@@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template v-if="hide">
 		<div :class="$style.hiddenText">
 			<div :class="$style.hiddenTextWrapper">
-				<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
-				<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b>
+				<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
+				<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b>
 				<span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span>
 			</div>
 		</div>
@@ -58,10 +58,10 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 import bytes from '@/filters/bytes.js';
 import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { $i, iAmModerator } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	image: Misskey.entities.DriveFile;
@@ -77,9 +77,9 @@ const props = withDefaults(defineProps<{
 
 const hide = ref(true);
 
-const url = computed(() => (props.raw || defaultStore.state.loadRawImages)
+const url = computed(() => (props.raw || prefer.s.loadRawImages)
 	? props.image.url
-	: defaultStore.state.disableShowingAnimatedImages
+	: prefer.s.disableShowingAnimatedImages
 		? getStaticImageUrl(props.image.url)
 		: props.image.thumbnailUrl,
 );
@@ -91,7 +91,7 @@ async function onclick(ev: MouseEvent) {
 
 	if (hide.value) {
 		ev.stopPropagation();
-		if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
+		if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
 			const { canceled } = await os.confirm({
 				type: 'question',
 				text: i18n.ts.sensitiveMediaRevealConfirm,
@@ -105,7 +105,7 @@ async function onclick(ev: MouseEvent) {
 
 // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
 watch(() => props.image, () => {
-	hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
+	hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore');
 }, {
 	deep: true,
 	immediate: true,
@@ -166,7 +166,7 @@ function showMenu(ev: MouseEvent) {
 		menuItems.push({ type: 'divider' }, ...details);
 	}
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFileId,
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 32766f2029..1c9494f8d6 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 			:class="[
 				$style.medias,
 				count === 1 ? [$style.n1, {
-					[$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9',
-					[$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1',
-					[$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3',
+					[$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9',
+					[$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1',
+					[$style.n12_3]: prefer.s.mediaListWithOneImageAppearance === '2_3',
 				}] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
 			]"
 		>
@@ -33,13 +33,13 @@ import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
 import 'photoswipe/style.css';
+import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
 import XBanner from '@/components/MkMediaBanner.vue';
 import XImage from '@/components/MkMediaImage.vue';
 import XVideo from '@/components/MkMediaVideo.vue';
 import * as os from '@/os.js';
-import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
-import { defaultStore } from '@/store.js';
 import { focusParent } from '@/scripts/focus.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	mediaList: Misskey.entities.DriveFile[];
@@ -75,7 +75,7 @@ async function calcAspectRatio() {
 		return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
 	};
 
-	switch (defaultStore.state.mediaListWithOneImageAppearance) {
+	switch (prefer.s.mediaListWithOneImageAppearance) {
 		case '16_9':
 			gallery.value.style.aspectRatio = ratioMax(16 / 9);
 			break;
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 682da22711..1433b17dc0 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:class="[
 		$style.videoContainer,
 		controlsShowing && $style.active,
-		(video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive,
+		(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
 	]"
 	@mouseover="onMouseOver"
 	@mouseleave="onMouseLeave"
@@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 >
 	<button v-if="hide" :class="$style.hidden" @click="show">
 		<div :class="$style.hiddenTextWrapper">
-			<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
-			<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
+			<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
+			<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
 			<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
 		</div>
 	</button>
 
-	<div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot">
+	<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot">
 		<video
 			ref="videoEl"
 			:class="$style.video"
@@ -116,13 +116,13 @@ import type { Keymap } from '@/scripts/hotkey.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js';
 import hasAudio from '@/scripts/media-has-audio.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
 import { $i, iAmModerator } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	video: Misskey.entities.DriveFile;
@@ -175,10 +175,10 @@ function hasFocus() {
 }
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
-const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
+const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'));
 
 async function show() {
-	if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
+	if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
 		const { canceled } = await os.confirm({
 			type: 'question',
 			text: i18n.ts.sensitiveMediaRevealConfirm,
@@ -265,7 +265,7 @@ function showMenu(ev: MouseEvent) {
 		menu.push({ type: 'divider' }, ...details);
 	}
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menu.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFileId,
@@ -502,7 +502,7 @@ onDeactivated(() => {
 	elapsedTimeMs.value = 0;
 	durationMs.value = 0;
 	bufferedEnd.value = 0;
-	hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore');
+	hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore');
 	stopVideoElWatch();
 	onceInit = false;
 	if (mediaTickFrameId) {
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index 87c82f0a89..ff3442abe0 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -19,8 +19,8 @@ import { computed } from 'vue';
 import { host as localHost } from '@@/js/config.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
 import { $i } from '@/account.js';
-import { defaultStore } from '@/store.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	username: string;
@@ -36,7 +36,7 @@ const isMe = $i && (
 	`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
 );
 
-const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar
+const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar
 	? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
 	: `/avatar/@${props.username}@${props.host}`,
 );
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 19588003fa..521c851d8b 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -43,13 +43,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue';
+import type { Keymap } from '@/scripts/hotkey.js';
 import * as os from '@/os.js';
 import { isTouchUsing } from '@/scripts/touch.js';
-import { defaultStore } from '@/store.js';
 import { deviceKind } from '@/scripts/device-kind.js';
-import type { Keymap } from '@/scripts/hotkey.js';
 import { focusTrap } from '@/scripts/focus-trap.js';
 import { focusParent } from '@/scripts/focus.js';
+import { prefer } from '@/preferences.js';
 
 function getFixedContainer(el: Element | null): Element | null {
 	if (el == null || el.tagName === 'BODY') return null;
@@ -106,7 +106,7 @@ const zIndex = os.claimZIndex(props.zPriority);
 const useSendAnime = ref(false);
 const type = computed<ModalTypes>(() => {
 	if (props.preferType === 'auto') {
-		if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) {
+		if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) {
 			return 'drawer';
 		} else {
 			return props.src != null ? 'popup' : 'dialog';
@@ -117,7 +117,7 @@ const type = computed<ModalTypes>(() => {
 });
 const isEnableBgTransparent = computed(() => props.transparentBg && (type.value === 'popup'));
 const transitionName = computed((() =>
-	defaultStore.state.animation
+	prefer.s.animation
 		? useSendAnime.value
 			? 'send'
 			: type.value === 'drawer'
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 8e1d854660..c88b5c16ee 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	v-show="!isDeleted"
 	ref="rootEl"
 	v-hotkey="keymap"
-	:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]"
+	:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
 	:tabindex="isDeleted ? '-1' : '0'"
 >
 	<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
@@ -130,9 +130,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
 					<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
 					<i v-else class="ti ti-plus"></i>
-					<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
+					<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
 				</button>
-				<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
+				<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
 					<i class="ti ti-paperclip"></i>
 				</button>
 				<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
@@ -178,13 +178,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, inject, onMounted, ref, shallowRef, watch, provide } from 'vue';
-import type { Ref } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
+import type { Ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
+import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import type { Keymap } from '@/scripts/hotkey.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
@@ -197,7 +199,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
 import { pleaseLogin } from '@/scripts/please-login.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { checkWordMute } from '@/scripts/check-word-mute.js';
 import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
@@ -205,7 +206,7 @@ import number from '@/filters/number.js';
 import * as os from '@/os.js';
 import * as sound from '@/scripts/sound.js';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { defaultStore, noteViewInterruptors } from '@/store.js';
+import { noteViewInterruptors } from '@/store.js';
 import { reactionPicker } from '@/scripts/reaction-picker.js';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
@@ -219,9 +220,9 @@ import { getNoteSummary } from '@/scripts/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { isEnabledUrlPreview } from '@/instance.js';
-import type { Keymap } from '@/scripts/hotkey.js';
 import { focusPrev, focusNext } from '@/scripts/focus.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -284,13 +285,13 @@ const collapsed = ref(appearNote.value.cw == null && isLong);
 const isDeleted = ref(false);
 const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
 const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
-const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord);
+const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
 const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
 const translating = ref(false);
-const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
+const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
 const renoteCollapsed = ref(
-	defaultStore.state.collapseRenotes && isRenote && (
+	prefer.s.collapseRenotes && isRenote && (
 		($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
 		(appearNote.value.myReaction != null)
 	),
@@ -345,7 +346,7 @@ const keymap = {
 	},
 	'c': () => {
 		if (renoteCollapsed.value) return;
-		if (!defaultStore.state.showClipButtonInNoteFooter) return;
+		if (!prefer.s.showClipButtonInNoteFooter) return;
 		clip();
 	},
 	'o': () => {
@@ -479,7 +480,7 @@ function react(): void {
 			reaction: '❤️',
 		});
 		const el = reactButton.value;
-		if (el && defaultStore.state.animation) {
+		if (el && prefer.s.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
@@ -490,7 +491,7 @@ function react(): void {
 	} else {
 		blur();
 		reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
-			if (defaultStore.state.confirmOnReact) {
+			if (prefer.s.confirmOnReact) {
 				const confirm = await os.confirm({
 					type: 'question',
 					text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
@@ -549,7 +550,7 @@ function onContextmenu(ev: MouseEvent): void {
 	if (ev.target && isLink(ev.target as HTMLElement)) return;
 	if (window.getSelection()?.toString() !== '') return;
 
-	if (defaultStore.state.useReactionPickerForContextMenu) {
+	if (prefer.s.useReactionPickerForContextMenu) {
 		ev.preventDefault();
 		react();
 	} else {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 27d8a399cc..75c08087f3 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -146,9 +146,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
 				<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
 				<i v-else class="ti ti-plus"></i>
-				<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
+				<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
 			</button>
-			<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
+			<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
 				<i class="ti ti-paperclip"></i>
 			</button>
 			<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
@@ -215,6 +215,9 @@ import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
+import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import type { Paging } from '@/components/MkPagination.vue';
+import type { Keymap } from '@/scripts/hotkey.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -226,7 +229,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
 import { pleaseLogin } from '@/scripts/please-login.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { checkWordMute } from '@/scripts/check-word-mute.js';
 import { userPage } from '@/filters/user.js';
 import { notePage } from '@/filters/note.js';
@@ -234,7 +236,7 @@ import number from '@/filters/number.js';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
 import * as sound from '@/scripts/sound.js';
-import { defaultStore, noteViewInterruptors } from '@/store.js';
+import { noteViewInterruptors } from '@/store.js';
 import { reactionPicker } from '@/scripts/reaction-picker.js';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
@@ -248,12 +250,11 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import type { Paging } from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
-import type { Keymap } from '@/scripts/hotkey.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -303,7 +304,7 @@ const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
 const translating = ref(false);
 const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
 const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
-const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
+const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
 const conversation = ref<Misskey.entities.Note[]>([]);
 const replies = ref<Misskey.entities.Note[]>([]);
 const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
@@ -319,7 +320,7 @@ const keymap = {
 	'q': () => renote(),
 	'm': () => showMenu(),
 	'c': () => {
-		if (!defaultStore.state.showClipButtonInNoteFooter) return;
+		if (!prefer.s.showClipButtonInNoteFooter) return;
 		clip();
 	},
 	'o': () => galleryEl.value?.openGallery(),
@@ -442,7 +443,7 @@ function react(): void {
 			reaction: '❤️',
 		});
 		const el = reactButton.value;
-		if (el && defaultStore.state.animation) {
+		if (el && prefer.s.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
@@ -453,7 +454,7 @@ function react(): void {
 	} else {
 		blur();
 		reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
-			if (defaultStore.state.confirmOnReact) {
+			if (prefer.s.confirmOnReact) {
 				const confirm = await os.confirm({
 					type: 'question',
 					text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
@@ -497,7 +498,7 @@ function onContextmenu(ev: MouseEvent): void {
 	if (ev.target && isLink(ev.target as HTMLElement)) return;
 	if (window.getSelection()?.toString() !== '') return;
 
-	if (defaultStore.state.useReactionPickerForContextMenu) {
+	if (prefer.s.useReactionPickerForContextMenu) {
 		ev.preventDefault();
 		react();
 	} else {
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index 750e32a9ff..8e5adec1a3 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -40,7 +40,6 @@ import * as Misskey from 'misskey-js';
 import { i18n } from '@/i18n.js';
 import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
-import { defaultStore } from '@/store.js';
 
 defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue
index e51ea5a2de..764d9f6a32 100644
--- a/packages/frontend/src/components/MkNoteMediaGrid.vue
+++ b/packages/frontend/src/components/MkNoteMediaGrid.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template v-for="file in note.files">
 	<div
 		v-if="(((
-				(defaultStore.state.nsfw === 'force' || file.isSensitive) &&
-				defaultStore.state.nsfw !== 'ignore'
-			) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) &&
+			(prefer.s.nsfw === 'force' || file.isSensitive) &&
+			prefer.s.nsfw !== 'ignore'
+		) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) &&
 			!showingFiles.has(file.id)
 		)"
 		:class="[$style.filePreview, { [$style.square]: square }]"
@@ -18,15 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkDriveFileThumbnail
 			:file="file"
 			fit="cover"
-			:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+			:highlightWhenSensitive="prefer.s.highlightSensitiveMedia"
 			:forceBlurhash="true"
 			:large="true"
 			:class="$style.file"
 		/>
 		<div :class="$style.sensitive">
 			<div>
-				<div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div>
-				<div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div>
+				<div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div>
+				<div v-else><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div>
 				<div>{{ i18n.ts.clickToShow }}</div>
 			</div>
 		</div>
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkDriveFileThumbnail
 			:file="file"
 			fit="cover"
-			:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+			:highlightWhenSensitive="prefer.s.highlightSensitiveMedia"
 			:large="true"
 			:class="$style.file"
 		/>
@@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
 import { notePage } from '@/filters/note.js';
 import { i18n } from '@/i18n.js';
-import * as Misskey from 'misskey-js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import bytes from '@/filters/bytes.js';
 
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 470837ace5..4275262429 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -25,17 +25,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { notificationTypes } from '@@/js/const.js';
 import MkPagination from '@/components/MkPagination.vue';
 import XNotification from '@/components/MkNotification.vue';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 import MkNote from '@/components/MkNote.vue';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import type { notificationTypes } from '@@/js/const.js';
 import { infoImageUrl } from '@/instance.js';
-import { defaultStore } from '@/store.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
-import * as Misskey from 'misskey-js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	excludeTypes?: typeof notificationTypes[number][];
@@ -43,7 +43,7 @@ const props = defineProps<{
 
 const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
 
-const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? {
+const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
 	endpoint: 'i/notifications-grouped' as const,
 	limit: 20,
 	params: computed(() => ({
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index d9135ab517..d4b0180ef9 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <Transition
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
 	mode="out-in"
 >
 	<MkLoading v-if="fetching"/>
@@ -44,15 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts">
 import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
-import type { ComputedRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import type { ComputedRef } from 'vue';
 import type { MisskeyEntity } from '@/types/date-separated-list.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
 
 const SECOND_FETCH_LIMIT = 30;
 const TOLERANCE = 16;
@@ -140,7 +139,7 @@ const empty = computed(() => items.value.size === 0);
 const error = ref(false);
 const {
 	enableInfiniteScroll,
-} = defaultStore.reactiveState;
+} = prefer.r;
 
 const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
 const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index b39a4ad708..6aa460b506 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
 	<div v-show="useCw" :class="$style.cwOuter">
 		<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
-		<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>		
+		<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>
 	</div>
 	<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
 		<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
@@ -123,7 +123,7 @@ import { Autocomplete } from '@/scripts/autocomplete.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { selectFiles } from '@/scripts/select-file.js';
-import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js';
+import { store, notePostInterruptors, postFormActions } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
@@ -135,6 +135,7 @@ import { miLocalStorage } from '@/local-storage.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { emojiPicker } from '@/scripts/emoji-picker.js';
 import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
+import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
 
@@ -174,19 +175,18 @@ const text = ref(props.initialText ?? '');
 const files = ref(props.initialFiles ?? []);
 const poll = ref<PollEditorModelValue | null>(null);
 const useCw = ref<boolean>(!!props.initialCw);
-const showPreview = ref(defaultStore.state.showPreview);
-watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
-const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
-watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value));
+const showPreview = ref(store.state.showPreview);
+watch(showPreview, () => store.set('showPreview', showPreview.value));
+const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction);
+watch(showAddMfmFunction, () => prefer.set('enableQuickAddMfmFunction', showAddMfmFunction.value));
 const cw = ref<string | null>(props.initialCw ?? null);
-const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly));
-const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility));
+const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly));
+const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility));
 const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]);
 if (props.initialVisibleUsers) {
 	props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
 }
-const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
-const autocomplete = ref(null);
+const reactionAcceptance = ref(store.state.reactionAcceptance);
 const draghover = ref(false);
 const quoteId = ref<string | null>(null);
 const hasNotSpecifiedMentions = ref(false);
@@ -268,8 +268,8 @@ const canPost = computed((): boolean => {
 		(!poll.value || poll.value.choices.length >= 2);
 });
 
-const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
-const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags'));
+const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
+const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
 
 watch(text, () => {
 	checkMissingMention();
@@ -357,7 +357,7 @@ if (props.specified) {
 }
 
 // keep cw when reply
-if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
+if (prefer.s.keepCw && props.reply && props.reply.cw) {
 	useCw.value = true;
 	cw.value = props.reply.cw;
 }
@@ -456,7 +456,7 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities
 function upload(file: File, name?: string): void {
 	if (props.mock) return;
 
-	uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
+	uploadFile(file, prefer.s.uploadFolder, name).then(res => {
 		files.value.push(res);
 	});
 }
@@ -477,8 +477,8 @@ function setVisibility() {
 	}, {
 		changeVisibility: v => {
 			visibility.value = v;
-			if (defaultStore.state.rememberNoteVisibility) {
-				defaultStore.set('visibility', visibility.value);
+			if (prefer.s.rememberNoteVisibility) {
+				store.set('visibility', visibility.value);
 			}
 		},
 		closed: () => dispose(),
@@ -525,8 +525,8 @@ async function toggleLocalOnly() {
 	}
 
 	localOnly.value = !localOnly.value;
-	if (defaultStore.state.rememberNoteVisibility) {
-		defaultStore.set('localOnly', localOnly.value);
+	if (prefer.s.rememberNoteVisibility) {
+		store.set('localOnly', localOnly.value);
 	}
 }
 
@@ -594,6 +594,8 @@ function onCompositionEnd(ev: CompositionEvent) {
 	justEndedComposition.value = true;
 }
 
+const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
+
 async function onPaste(ev: ClipboardEvent) {
 	if (props.mock) return;
 	if (!ev.clipboardData) return;
@@ -604,7 +606,7 @@ async function onPaste(ev: ClipboardEvent) {
 			if (!file) continue;
 			const lio = file.name.lastIndexOf('.');
 			const ext = lio >= 0 ? file.name.slice(lio) : '';
-			const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+			const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
 			upload(file, formatted);
 		}
 	}
@@ -638,7 +640,7 @@ async function onPaste(ev: ClipboardEvent) {
 				return;
 			}
 
-			const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0');
+			const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
 			const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
 			upload(file, `${fileName}.txt`);
 		});
@@ -751,7 +753,7 @@ async function post(ev?: MouseEvent) {
 	if (ev) {
 		const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;
 
-		if (el && defaultStore.state.animation) {
+		if (el && prefer.s.animation) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
 			const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index c7774d50b2..83d0d66cc3 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -36,12 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent, inject } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu';
-import { defaultStore } from '@/store';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard';
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
@@ -198,7 +198,7 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
 		action: () => { detachAndDeleteMedia(file); },
 	});
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFileId,
diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue
new file mode 100644
index 0000000000..85fab462cd
--- /dev/null
+++ b/packages/frontend/src/components/MkPreferenceContainer.vue
@@ -0,0 +1,94 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+	<div :class="$style.body">
+		<slot></slot>
+	</div>
+	<div :class="$style.menu">
+		<i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i>
+		<div :class="$style.buttons">
+			<button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu"><i class="ti ti-dots"></i></button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import type { PREF_DEF } from '@/preferences/def.js';
+import * as os from '@/os.js';
+import { profileManager } from '@/preferences.js';
+
+const props = withDefaults(defineProps<{
+	k: keyof typeof PREF_DEF;
+}>(), {
+});
+
+const isAccountOverrided = ref(profileManager.isAccountOverrided(props.k));
+
+function showMenu(ev: MouseEvent) {
+	const i = window.setInterval(() => {
+		isAccountOverrided.value = profileManager.isAccountOverrided(props.k);
+	}, 100);
+	os.popupMenu(profileManager.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, {
+		onClosing: () => {
+			window.clearInterval(i);
+		},
+	});
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	display: flex;
+
+	&:hover {
+		&::after {
+			content: '';
+			position: absolute;
+			top: -8px;
+			left: -8px;
+			width: calc(100% + 16px);
+			height: calc(100% + 16px);
+			border-radius: 8px;
+			background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.02));
+			pointer-events: none;
+		}
+
+		.menu {
+			.buttons {
+				opacity: 0.7;
+			}
+		}
+	}
+
+	.body {
+		flex: 1;
+	}
+
+	.menu {
+		display: flex;
+		gap: 8px;
+		align-items: center;
+		margin-left: 12px;
+		font-size: 12px;
+		padding-left: 8px;
+		border-left: solid 1px var(--MI_THEME-divider);
+
+		&:hover {
+			.buttons {
+				opacity: 1;
+			}
+		}
+
+		.buttons {
+			opacity: 0.3;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 41e475eade..2e453aeb8f 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -8,11 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	ref="buttonEl"
 	v-ripple="canToggle"
 	class="_button"
-	:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
+	:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
 	@click="toggleReaction()"
 	@contextmenu.prevent.stop="menu"
 >
-	<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
+	<MkReactionIcon :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
 	<span :class="$style.count">{{ count }}</span>
 </button>
 </template>
@@ -30,11 +30,11 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
 import { $i } from '@/account.js';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
 import { claimAchievement } from '@/scripts/achievements.js';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import * as sound from '@/scripts/sound.js';
 import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
 import { customEmojisMap } from '@/custom-emojis.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	reaction: string;
@@ -90,7 +90,7 @@ async function toggleReaction() {
 			}
 		});
 	} else {
-		if (defaultStore.state.confirmOnReact) {
+		if (prefer.s.confirmOnReact) {
 			const confirm = await os.confirm({
 				type: 'question',
 				text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }),
@@ -135,7 +135,7 @@ async function menu(ev) {
 }
 
 function anime() {
-	if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return;
+	if (document.hidden || !prefer.s.animation || buttonEl.value == null) return;
 
 	const rect = buttonEl.value.getBoundingClientRect();
 	const x = rect.left + 16;
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 63b202f9f3..bb60db8d34 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <TransitionGroup
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''"
-	:moveClass="defaultStore.state.animation ? $style.transition_x_move : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
+	:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
 	tag="div" :class="$style.root"
 >
 	<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { inject, watch, ref } from 'vue';
 import XReaction from '@/components/MkReactionsViewer.reaction.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 64b573c4d3..3ca680d8f2 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, nextTick, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { alpha } from '@/scripts/color.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -75,7 +75,7 @@ async function renderChart() {
 
 	await nextTick();
 
-	const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
+	const color = store.state.darkMode ? '#b4e900' : '#86b300';
 
 	const getYYYYMMDD = (date: Date) => {
 		const y = date.getFullYear().toString().padStart(2, '0');
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index d41793b0fa..13cee1b1cd 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { alpha } from '@/scripts/color.js';
@@ -42,7 +42,7 @@ const getDate = (ymd: string) => {
 onMounted(async () => {
 	let raw = await misskeyApi('retention', { });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent'));
 	const color = accent.toHex();
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 7bae240ddd..3cc9b69341 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		v-if="paginationQuery"
 		ref="tlComponent"
 		:pagination="paginationQuery"
-		:noGap="!defaultStore.state.showGapBetweenNotesInTimeline"
+		:noGap="!prefer.s.showGapBetweenNotesInTimeline"
 		@queue="emit('queue', $event)"
 		@status="prComponent?.setDisabled($event)"
 	/>
@@ -20,14 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { BasicTimelineType } from '@/timelines.js';
+import type { Paging } from '@/components/MkPagination.vue';
 import MkNotes from '@/components/MkNotes.vue';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { useStream } from '@/stream.js';
 import * as sound from '@/scripts/sound.js';
 import { $i } from '@/account.js';
 import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
-import type { Paging } from '@/components/MkPagination.vue';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -239,7 +239,7 @@ function updatePaginationQuery() {
 }
 
 function refreshEndpointAndChannel() {
-	if (!defaultStore.state.disableStreamingTimeline) {
+	if (!prefer.s.disableStreamingTimeline) {
 		disconnectChannel();
 		connectChannel();
 	}
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
index e256640649..ac795e312c 100644
--- a/packages/frontend/src/components/MkToast.vue
+++ b/packages/frontend/src/components/MkToast.vue
@@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div>
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_toast_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_toast_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_toast_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_toast_leaveTo : ''"
 		appear @afterLeave="emit('closed')"
 	>
 		<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 defineProps<{
 	message: string;
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
index 10365d29b1..c30ca1ffe6 100644
--- a/packages/frontend/src/components/MkTooltip.vue
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <Transition
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_tooltip_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_tooltip_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_tooltip_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_tooltip_leaveTo : ''"
 	appear @afterLeave="emit('closed')"
 >
 	<div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }">
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
 import * as os from '@/os.js';
 import { calcPopupPosition } from '@/scripts/popup-position.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	showing: boolean;
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 063e5dcad2..850dd2d257 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin"
 			scrolling="no"
 			:style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }"
-			:src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"
+			:src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"
 		></iframe>
 	</div>
 	<div :class="$style.action">
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 <div v-else>
 	<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
-		<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
+		<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
 		</div>
 		<article :class="$style.body">
 			<header :class="$style.header">
@@ -92,7 +92,8 @@ import * as os from '@/os.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import MkButton from '@/components/MkButton.vue';
 import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 type SummalyResult = Awaited<ReturnType<typeof summaly>>;
 
diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue
index e972973dba..fd36d6a82b 100644
--- a/packages/frontend/src/components/MkUrlPreviewPopup.vue
+++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
-	<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
+	<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
 		<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/>
 	</Transition>
 </div>
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, ref } from 'vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	showing: boolean;
diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index 0164515a8a..9abf61e893 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_panel" :class="$style.root">
-	<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div>
+	<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div>
 	<MkAvatar :class="$style.avatar" :user="user" indicator/>
 	<div :class="$style.title">
 		<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
@@ -42,7 +42,7 @@ import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 defineProps<{
 	user: Misskey.entities.UserDetailed;
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 740202f28b..4027893a8e 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <Transition
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_popup_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_popup_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_popup_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_popup_leaveTo : ''"
 	appear @afterLeave="emit('closed')"
 >
 	<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
 		<div v-if="user != null">
-			<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''">
+			<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''">
 				<span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
 			</div>
 			<svg viewBox="0 0 128 128" :class="$style.avatarBack">
@@ -64,7 +64,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { getUserMenu } from '@/scripts/get-user-menu.js';
 import number from '@/filters/number.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index 1e93d9dbea..2c5757e24e 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue';
 import FormSplit from '@/components/form/split.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { instance } from '@/instance.js';
@@ -128,10 +128,10 @@ async function ok() {
 	dialogEl.value?.close();
 
 	// 最近使ったユーザー更新
-	let recents = defaultStore.state.recentlyUsedUsers;
+	let recents = store.state.recentlyUsedUsers;
 	recents = recents.filter(x => x !== selected.value?.id);
 	recents.unshift(selected.value.id);
-	defaultStore.set('recentlyUsedUsers', recents.splice(0, 16));
+	store.set('recentlyUsedUsers', recents.splice(0, 16));
 }
 
 function cancel() {
@@ -141,7 +141,7 @@ function cancel() {
 
 onMounted(() => {
 	misskeyApi('users/show', {
-		userIds: defaultStore.state.recentlyUsedUsers,
+		userIds: store.state.recentlyUsedUsers,
 	}).then(foundUsers => {
 		let _users = foundUsers;
 		_users = _users.filter((u) => {
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
index b7261129ef..cba6f6fd90 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -139,7 +139,7 @@ import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { host } from '@@/js/config.js';
 import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
 
 const emit = defineEmits<{
@@ -149,10 +149,10 @@ const emit = defineEmits<{
 const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
-const page = ref(defaultStore.state.accountSetupWizard);
+const page = ref(store.state.accountSetupWizard);
 
 watch(page, () => {
-	defaultStore.set('accountSetupWizard', page.value);
+	store.set('accountSetupWizard', page.value);
 });
 
 async function close(skip: boolean) {
@@ -165,11 +165,11 @@ async function close(skip: boolean) {
 	}
 
 	dialog.value?.close();
-	defaultStore.set('accountSetupWizard', -1);
+	store.set('accountSetupWizard', -1);
 }
 
 function setupComplete() {
-	defaultStore.set('accountSetupWizard', -1);
+	store.set('accountSetupWizard', -1);
 	dialog.value?.close();
 }
 
@@ -194,7 +194,7 @@ async function later(later: boolean) {
 	}
 
 	dialog.value?.close();
-	defaultStore.set('accountSetupWizard', 0);
+	store.set('accountSetupWizard', 0);
 }
 </script>
 
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index d098dad9a1..ac48b11c3f 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -59,7 +59,7 @@ async function renderChart() {
 
 	await nextTick();
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const computedStyle = getComputedStyle(document.documentElement);
 	const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 2953f656d4..4759d217e8 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <Transition
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_window_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_window_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_window_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_window_leaveTo : ''"
 	appear
 	@afterLeave="emit('closed')"
 >
@@ -58,7 +58,7 @@ import type { MenuItem } from '@/types/menu.js';
 import contains from '@/scripts/contains.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 type WindowButton = {
 	title: string;
diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue
index 1122976436..1c069d65fc 100644
--- a/packages/frontend/src/components/MkYouTubePlayer.vue
+++ b/packages/frontend/src/components/MkYouTubePlayer.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</template>
 
 	<div class="poamfof">
-		<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
+		<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
 			<div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
 				<iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
 			</div>
@@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref } from 'vue';
-import MkWindow from '@/components/MkWindow.vue';
 import { versatileLang } from '@@/js/intl-const.js';
+import MkWindow from '@/components/MkWindow.vue';
 import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	url: string;
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 08a78c8d81..4f9bfd02bd 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -45,9 +45,10 @@ import { url as local, host } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import MkButton from '@/components/MkButton.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { $i } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 type Ad = (typeof instance)['ads'][number];
 
@@ -66,7 +67,7 @@ const choseAd = (): Ad | null => {
 		return props.specify;
 	}
 
-	const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
+	const allAds = instance.ads.map(ad => store.state.mutedAds.includes(ad.id) ? {
 		...ad,
 		ratio: 0,
 	} : ad);
@@ -107,12 +108,12 @@ const chosen = ref(choseAd());
 
 const self = computed(() => chosen.value?.url.startsWith(local));
 
-const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null));
+const shouldHide = ref(!prefer.s.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null));
 
 function reduceFrequency(): void {
 	if (chosen.value == null) return;
-	if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
-	defaultStore.push('mutedAds', chosen.value.id);
+	if (store.state.mutedAds.includes(chosen.value.id)) return;
+	store.push('mutedAds', chosen.value.id);
 	os.success();
 	chosen.value = choseAd();
 	showMenu.value = false;
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 35c07bc80c..71c309fd89 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -48,11 +48,10 @@ import MkA from './MkA.vue';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 import { acct, userPage } from '@/filters/user.js';
 import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
-const animation = ref(defaultStore.state.animation);
-const squareAvatars = ref(defaultStore.state.squareAvatars);
-const useBlurEffect = ref(defaultStore.state.useBlurEffect);
+const animation = ref(prefer.s.animation);
+const squareAvatars = ref(prefer.s.squareAvatars);
 
 const props = withDefaults(defineProps<{
 	user: Misskey.entities.User;
@@ -75,7 +74,7 @@ const emit = defineEmits<{
 	(ev: 'click', v: MouseEvent): void;
 }>();
 
-const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations;
+const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations;
 
 const bound = computed(() => props.link
 	? { to: userPage(props.user), target: props.target }
@@ -83,7 +82,7 @@ const bound = computed(() => props.link
 
 const url = computed(() => {
 	if (props.user.avatarUrl == null) return null;
-	if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl);
+	if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl);
 	return props.user.avatarUrl;
 });
 
@@ -93,7 +92,7 @@ function onClick(ev: MouseEvent): void {
 }
 
 function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
-	if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url);
+	if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url);
 	return decoration.url;
 }
 
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index ec1d859080..82c5e2a1dc 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -28,7 +28,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, defineAsyncComponent, inject, ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { defaultStore } from '@/store.js';
 import { customEmojisMap } from '@/custom-emojis.js';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
@@ -37,6 +36,7 @@ import * as sound from '@/scripts/sound.js';
 import { i18n } from '@/i18n.js';
 import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
 import { $i } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	name: string;
@@ -77,7 +77,7 @@ const url = computed(() => {
 				false,
 				true,
 			);
-	return defaultStore.reactiveState.disableShowingAnimatedImages.value
+	return prefer.s.disableShowingAnimatedImages
 		? getStaticImageUrl(proxied)
 		: proxied;
 });
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
index f0acd3bc27..a00004fdb8 100644
--- a/packages/frontend/src/components/global/MkEmoji.vue
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -12,12 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, inject } from 'vue';
 import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js';
 import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js';
-import { defaultStore } from '@/store.js';
+import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import * as sound from '@/scripts/sound.js';
 import { i18n } from '@/i18n.js';
-import type { MenuItem } from '@/types/menu.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	emoji: string;
@@ -27,9 +27,9 @@ const props = defineProps<{
 
 const react = inject<((name: string) => void) | null>('react', null);
 
-const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
+const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
 
-const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
+const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native');
 const url = computed(() => char2path(props.emoji));
 const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
 
diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue
index c594cc752b..b07e0775a3 100644
--- a/packages/frontend/src/components/global/MkError.vue
+++ b/packages/frontend/src/components/global/MkError.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear>
+<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
 	<div :class="$style.root">
 		<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
 		<p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { serverErrorImageUrl } from '@/instance.js';
 
 const emit = defineEmits<{
diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue
index 1a75855fa1..d08645aaf3 100644
--- a/packages/frontend/src/components/global/MkFooterSpacer.vue
+++ b/packages/frontend/src/components/global/MkFooterSpacer.vue
@@ -4,11 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="[$style.spacer, defaultStore.reactiveState.darkMode.value ? $style.dark : $style.light]"></div>
+<div :class="[$style.spacer, store.reactiveState.darkMode.value ? $style.dark : $style.light]"></div>
 </template>
 
 <script lang="ts" setup>
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts
index 48d7e34d76..337e326ccd 100644
--- a/packages/frontend/src/components/global/MkMfm.ts
+++ b/packages/frontend/src/components/global/MkMfm.ts
@@ -3,11 +3,12 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { h, provide } from 'vue';
-import type { VNode, SetupContext } from 'vue';
+import { h } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { host } from '@@/js/config.js';
+import type { VNode, SetupContext } from 'vue';
+import type { MkABehavior } from '@/components/global/MkA.vue';
 import MkUrl from '@/components/global/MkUrl.vue';
 import MkTime from '@/components/global/MkTime.vue';
 import MkLink from '@/components/MkLink.vue';
@@ -19,8 +20,7 @@ import MkCodeInline from '@/components/MkCodeInline.vue';
 import MkGoogle from '@/components/MkGoogle.vue';
 import MkSparkle from '@/components/MkSparkle.vue';
 import MkA from '@/components/global/MkA.vue';
-import type { MkABehavior } from '@/components/global/MkA.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 function safeParseFloat(str: unknown): number | null {
 	if (typeof str !== 'string' || str === '') return null;
@@ -81,7 +81,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 		return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
 	};
 
-	const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
+	const useAnim = prefer.s.advancedMfm && prefer.s.animatedMfm;
 
 	/**
 	 * Gen Vue Elements from MFM AST
@@ -188,17 +188,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 					}
 					case 'x2': {
 						return h('span', {
-							class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
+							class: prefer.s.advancedMfm ? 'mfm-x2' : '',
 						}, genEl(token.children, scale * 2));
 					}
 					case 'x3': {
 						return h('span', {
-							class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
+							class: prefer.s.advancedMfm ? 'mfm-x3' : '',
 						}, genEl(token.children, scale * 3));
 					}
 					case 'x4': {
 						return h('span', {
-							class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
+							class: prefer.s.advancedMfm ? 'mfm-x4' : '',
 						}, genEl(token.children, scale * 4));
 					}
 					case 'font': {
@@ -241,14 +241,14 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 						break;
 					}
 					case 'position': {
-						if (!defaultStore.state.advancedMfm) break;
+						if (!prefer.s.advancedMfm) break;
 						const x = safeParseFloat(token.props.args.x) ?? 0;
 						const y = safeParseFloat(token.props.args.y) ?? 0;
 						style = `transform: translateX(${x}em) translateY(${y}em);`;
 						break;
 					}
 					case 'scale': {
-						if (!defaultStore.state.advancedMfm) {
+						if (!prefer.s.advancedMfm) {
 							style = '';
 							break;
 						}
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index aaef8b8fca..8a5e556293 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.tabsInner">
 		<button
 			v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
-			class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
+			class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]"
 			@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
 		>
 			<div :class="$style.tabInner">
 				<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
 				<div
-					v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
+					v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)"
 					:class="$style.tabTitle"
 				>
 					{{ t.title }}
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</div>
 	<div
 		ref="tabHighlightEl"
-		:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"
+		:class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]"
 	></div>
 </div>
 </template>
@@ -41,20 +41,20 @@ export type Tab = {
 	onClick?: (ev: MouseEvent) => void;
 } & (
 	| {
-			iconOnly?: false;
-			title: string;
-			icon?: string;
-		}
+		iconOnly?: false;
+		title: string;
+		icon?: string;
+	}
 	| {
-			iconOnly: true;
-			icon: string;
-		}
+		iconOnly: true;
+		icon: string;
+	}
 );
 </script>
 
 <script lang="ts" setup>
 import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
 	tabs?: Tab[];
diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue
index 3ab3d10a40..2e963daa27 100644
--- a/packages/frontend/src/components/global/RouterView.vue
+++ b/packages/frontend/src/components/global/RouterView.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <KeepAlive
-	:max="defaultStore.state.numberOfPageCache"
+	:max="prefer.s.numberOfPageCache"
 	:exclude="pageCacheController"
 >
 	<Suspense :timeout="0">
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue';
 import type { IRouter, Resolved, RouteDef } from '@/nirax.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { globalEvents } from '@/events.js';
 import MkLoadingPage from '@/pages/_loading_.vue';
 
diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue
index c5ec626cf4..66a78cb7fd 100644
--- a/packages/frontend/src/components/global/SearchMarker.vue
+++ b/packages/frontend/src/components/global/SearchMarker.vue
@@ -36,7 +36,7 @@ const rootEl = useTemplateRef('root');
 const rootElMutationObserver = new MutationObserver(() => {
 	checkChildren();
 });
-const injectedSearchMarkerId = inject<Ref<string | null>>('inAppSearchMarkerId');
+const injectedSearchMarkerId = inject<Ref<string | null> | null>('inAppSearchMarkerId', null);
 const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1));
 const highlighted = ref(props.markerId === searchMarkerId.value);
 
diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts
new file mode 100644
index 0000000000..dee9a3db78
--- /dev/null
+++ b/packages/frontend/src/deck.ts
@@ -0,0 +1,306 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { throttle } from 'throttle-debounce';
+import { notificationTypes } from 'misskey-js';
+import type { BasicTimelineType } from '@/timelines.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { deepClone } from '@/scripts/clone.js';
+import { store } from '@/store.js';
+
+type ColumnWidget = {
+	name: string;
+	id: string;
+	data: Record<string, any>;
+};
+
+export const columnTypes = [
+	'main',
+	'widgets',
+	'notifications',
+	'tl',
+	'antenna',
+	'list',
+	'channel',
+	'mentions',
+	'direct',
+	'roleTimeline',
+] as const;
+
+export type ColumnType = typeof columnTypes[number];
+
+export type Column = {
+	id: string;
+	type: ColumnType;
+	name: string | null;
+	width: number;
+	widgets?: ColumnWidget[];
+	active?: boolean;
+	flexible?: boolean;
+	antennaId?: string;
+	listId?: string;
+	channelId?: string;
+	roleId?: string;
+	excludeTypes?: typeof notificationTypes[number][];
+	tl?: BasicTimelineType;
+	withRenotes?: boolean;
+	withReplies?: boolean;
+	withSensitive?: boolean;
+	onlyFiles?: boolean;
+	soundSetting?: SoundStore;
+};
+
+export const loadDeck = async () => {
+	let deck;
+
+	try {
+		deck = await misskeyApi('i/registry/get', {
+			scope: ['client', 'deck', 'profiles'],
+			key: store.state['deck.profile'],
+		});
+	} catch (err) {
+		if (typeof err === 'object' && err != null && 'code' in err && err.code === 'NO_SUCH_KEY') {
+			// 後方互換性のため
+			if (store.state['deck.profile'] === 'default') {
+				saveDeck();
+				return;
+			}
+
+			store.set('deck.columns', []);
+			store.set('deck.layout', []);
+			return;
+		}
+		throw err;
+	}
+
+	store.set('deck.columns', deck.columns);
+	store.set('deck.layout', deck.layout);
+};
+
+export async function forceSaveDeck() {
+	await misskeyApi('i/registry/set', {
+		scope: ['client', 'deck', 'profiles'],
+		key: store.state['deck.profile'],
+		value: {
+			columns: store.reactiveState['deck.columns'].value,
+			layout: store.reactiveState['deck.layout'].value,
+		},
+	});
+}
+
+// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
+export const saveDeck = throttle(1000, () => {
+	forceSaveDeck();
+});
+
+export async function getProfiles(): Promise<string[]> {
+	return await misskeyApi('i/registry/keys', {
+		scope: ['client', 'deck', 'profiles'],
+	});
+}
+
+export async function deleteProfile(key: string): Promise<void> {
+	return await misskeyApi('i/registry/remove', {
+		scope: ['client', 'deck', 'profiles'],
+		key: key,
+	});
+}
+
+export function addColumn(column: Column) {
+	if (column.name === undefined) column.name = null;
+	store.push('deck.columns', column);
+	store.push('deck.layout', [column.id]);
+	saveDeck();
+}
+
+export function removeColumn(id: Column['id']) {
+	store.set('deck.columns', store.state['deck.columns'].filter(c => c.id !== id));
+	store.set('deck.layout', store.state['deck.layout']
+		.map(ids => ids.filter(_id => _id !== id))
+		.filter(ids => ids.length > 0));
+	saveDeck();
+}
+
+export function swapColumn(a: Column['id'], b: Column['id']) {
+	const aX = store.state['deck.layout'].findIndex(ids => ids.indexOf(a) !== -1);
+	const aY = store.state['deck.layout'][aX].findIndex(id => id === a);
+	const bX = store.state['deck.layout'].findIndex(ids => ids.indexOf(b) !== -1);
+	const bY = store.state['deck.layout'][bX].findIndex(id => id === b);
+	const layout = deepClone(store.state['deck.layout']);
+	layout[aX][aY] = b;
+	layout[bX][bY] = a;
+	store.set('deck.layout', layout);
+	saveDeck();
+}
+
+export function swapLeftColumn(id: Column['id']) {
+	const layout = deepClone(store.state['deck.layout']);
+	store.state['deck.layout'].some((ids, i) => {
+		if (ids.includes(id)) {
+			const left = store.state['deck.layout'][i - 1];
+			if (left) {
+				layout[i - 1] = store.state['deck.layout'][i];
+				layout[i] = left;
+				store.set('deck.layout', layout);
+			}
+			return true;
+		}
+		return false;
+	});
+	saveDeck();
+}
+
+export function swapRightColumn(id: Column['id']) {
+	const layout = deepClone(store.state['deck.layout']);
+	store.state['deck.layout'].some((ids, i) => {
+		if (ids.includes(id)) {
+			const right = store.state['deck.layout'][i + 1];
+			if (right) {
+				layout[i + 1] = store.state['deck.layout'][i];
+				layout[i] = right;
+				store.set('deck.layout', layout);
+			}
+			return true;
+		}
+		return false;
+	});
+	saveDeck();
+}
+
+export function swapUpColumn(id: Column['id']) {
+	const layout = deepClone(store.state['deck.layout']);
+	const idsIndex = store.state['deck.layout'].findIndex(ids => ids.includes(id));
+	const ids = deepClone(store.state['deck.layout'][idsIndex]);
+	ids.some((x, i) => {
+		if (x === id) {
+			const up = ids[i - 1];
+			if (up) {
+				ids[i - 1] = id;
+				ids[i] = up;
+
+				layout[idsIndex] = ids;
+				store.set('deck.layout', layout);
+			}
+			return true;
+		}
+		return false;
+	});
+	saveDeck();
+}
+
+export function swapDownColumn(id: Column['id']) {
+	const layout = deepClone(store.state['deck.layout']);
+	const idsIndex = store.state['deck.layout'].findIndex(ids => ids.includes(id));
+	const ids = deepClone(store.state['deck.layout'][idsIndex]);
+	ids.some((x, i) => {
+		if (x === id) {
+			const down = ids[i + 1];
+			if (down) {
+				ids[i + 1] = id;
+				ids[i] = down;
+
+				layout[idsIndex] = ids;
+				store.set('deck.layout', layout);
+			}
+			return true;
+		}
+		return false;
+	});
+	saveDeck();
+}
+
+export function stackLeftColumn(id: Column['id']) {
+	let layout = deepClone(store.state['deck.layout']);
+	const i = store.state['deck.layout'].findIndex(ids => ids.includes(id));
+	layout = layout.map(ids => ids.filter(_id => _id !== id));
+	layout[i - 1].push(id);
+	layout = layout.filter(ids => ids.length > 0);
+	store.set('deck.layout', layout);
+	saveDeck();
+}
+
+export function popRightColumn(id: Column['id']) {
+	let layout = deepClone(store.state['deck.layout']);
+	const i = store.state['deck.layout'].findIndex(ids => ids.includes(id));
+	const affected = layout[i];
+	layout = layout.map(ids => ids.filter(_id => _id !== id));
+	layout.splice(i + 1, 0, [id]);
+	layout = layout.filter(ids => ids.length > 0);
+	store.set('deck.layout', layout);
+
+	const columns = deepClone(store.state['deck.columns']);
+	for (const column of columns) {
+		if (affected.includes(column.id)) {
+			column.active = true;
+		}
+	}
+	store.set('deck.columns', columns);
+
+	saveDeck();
+}
+
+export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
+	const columns = deepClone(store.state['deck.columns']);
+	const columnIndex = store.state['deck.columns'].findIndex(c => c.id === id);
+	const column = deepClone(store.state['deck.columns'][columnIndex]);
+	if (column == null) return;
+	if (column.widgets == null) column.widgets = [];
+	column.widgets.unshift(widget);
+	columns[columnIndex] = column;
+	store.set('deck.columns', columns);
+	saveDeck();
+}
+
+export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
+	const columns = deepClone(store.state['deck.columns']);
+	const columnIndex = store.state['deck.columns'].findIndex(c => c.id === id);
+	const column = deepClone(store.state['deck.columns'][columnIndex]);
+	if (column == null) return;
+	if (column.widgets == null) column.widgets = [];
+	column.widgets = column.widgets.filter(w => w.id !== widget.id);
+	columns[columnIndex] = column;
+	store.set('deck.columns', columns);
+	saveDeck();
+}
+
+export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
+	const columns = deepClone(store.state['deck.columns']);
+	const columnIndex = store.state['deck.columns'].findIndex(c => c.id === id);
+	const column = deepClone(store.state['deck.columns'][columnIndex]);
+	if (column == null) return;
+	column.widgets = widgets;
+	columns[columnIndex] = column;
+	store.set('deck.columns', columns);
+	saveDeck();
+}
+
+export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
+	const columns = deepClone(store.state['deck.columns']);
+	const columnIndex = store.state['deck.columns'].findIndex(c => c.id === id);
+	const column = deepClone(store.state['deck.columns'][columnIndex]);
+	if (column == null) return;
+	if (column.widgets == null) column.widgets = [];
+	column.widgets = column.widgets.map(w => w.id === widgetId ? {
+		...w,
+		data: widgetData,
+	} : w);
+	columns[columnIndex] = column;
+	store.set('deck.columns', columns);
+	saveDeck();
+}
+
+export function updateColumn(id: Column['id'], column: Partial<Column>) {
+	const columns = deepClone(store.state['deck.columns']);
+	const columnIndex = store.state['deck.columns'].findIndex(c => c.id === id);
+	const currentColumn = deepClone(store.state['deck.columns'][columnIndex]);
+	if (currentColumn == null) return;
+	for (const [k, v] of Object.entries(column)) {
+		currentColumn[k] = v;
+	}
+	columns[columnIndex] = currentColumn;
+	store.set('deck.columns', columns);
+	saveDeck();
+}
diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts
index 60242837f2..c34f351fb3 100644
--- a/packages/frontend/src/directives/click-anime.ts
+++ b/packages/frontend/src/directives/click-anime.ts
@@ -4,11 +4,11 @@
  */
 
 import type { Directive } from 'vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 export default {
 	mounted(el: HTMLElement, binding, vn) {
-		if (!defaultStore.state.animation) return;
+		if (!prefer.s.animation) return;
 
 		const target = el.children[0];
 
diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts
index 99845c57c3..614cd37011 100644
--- a/packages/frontend/src/directives/ripple.ts
+++ b/packages/frontend/src/directives/ripple.ts
@@ -4,14 +4,14 @@
  */
 
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { popup } from '@/os.js';
 
 export default {
 	mounted(el, binding, vn) {
 		// 明示的に false であればバインドしない
 		if (binding.value === false) return;
-		if (!defaultStore.state.animation) return;
+		if (!prefer.s.animation) return;
 
 		el.addEventListener('click', () => {
 			const rect = el.getBoundingClientRect();
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 9c8863f863..3977edb91b 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -19,7 +19,6 @@ export type Keys = (
 	'drafts' |
 	'hashtags' |
 	'wallpaper' |
-	'theme' |
 	'colorScheme' |
 	'useSystemFont' |
 	'fontSize' |
@@ -28,13 +27,17 @@ export type Keys = (
 	'locale' |
 	'localeVersion' |
 	'theme' |
+	'themeId' |
 	'customCss' |
 	'message_drafts' |
 	'scratchpad' |
 	'debug' |
+	'preferences' |
+	'latestPreferencesUpdate' |
+	'hidePreferencesRestoreSuggestion' |
 	`miux:${string}` |
 	`ui:folder:${string}` |
-	`themes:${string}` |
+	`themes:${string}` | // DEPRECATED
 	`aiscript:${string}` |
 	'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
 	'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~);
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 6e48366092..2142432953 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -6,15 +6,15 @@
 // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
 
 import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue';
-import type { Component, Ref } from 'vue';
 import { EventEmitter } from 'eventemitter3';
 import * as Misskey from 'misskey-js';
+import type { Component, Ref } from 'vue';
 import type { ComponentProps as CP } from 'vue-component-type-helpers';
 import type { Form, GetFormResultType } from '@/scripts/form.js';
 import type { MenuItem } from '@/types/menu.js';
 import type { PostFormProps } from '@/types/post-form.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
 import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
@@ -626,7 +626,7 @@ export async function selectRole(params: {
 }): Promise<
 	{ canceled: true; result: undefined; } |
 	{ canceled: false; result: Misskey.entities.Role[] }
-> {
+	> {
 	return new Promise((resolve) => {
 		popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, {
 			done: roles => {
@@ -699,8 +699,8 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n
 
 export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
 	if (
-		defaultStore.state.contextMenu === 'native' ||
-		(defaultStore.state.contextMenu === 'appWithShift' && !ev.shiftKey)
+		prefer.s.contextMenu === 'native' ||
+		(prefer.s.contextMenu === 'appWithShift' && !ev.shiftKey)
 	) {
 		return Promise.resolve();
 	}
diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue
index f09a8e4285..9e3a6777f1 100644
--- a/packages/frontend/src/pages/_error_.vue
+++ b/packages/frontend/src/pages/_error_.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <MkLoading v-if="!loaded"/>
-<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear>
+<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
 	<div v-show="loaded" :class="$style.root">
 		<img :src="serverErrorImageUrl" class="_ghost" :class="$style.img"/>
 		<div class="_gaps">
@@ -27,15 +27,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
+import { version } from '@@/js/config.js';
 import MkButton from '@/components/MkButton.vue';
 import MkLink from '@/components/MkLink.vue';
-import { version } from '@@/js/config.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { serverErrorImageUrl } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 29772ae00a..6e5b52ea8a 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -143,7 +143,7 @@ import MkInfo from '@/components/MkInfo.vue';
 import { physics } from '@/scripts/physics.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
@@ -406,7 +406,7 @@ const easterEggEngine = ref<{ stop: () => void } | null>(null);
 const containerEl = shallowRef<HTMLElement>();
 
 function iconLoaded() {
-	const emojis = defaultStore.state.reactions;
+	const emojis = store.state.reactions;
 	const containerWidth = containerEl.value.offsetWidth;
 	for (let i = 0; i < 32; i++) {
 		easterEggEmojis.value.push({
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index 22173bb888..c7a45a502e 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
 			</div>
 
-			<MkInfo v-if="!defaultStore.reactiveState.abusesTutorial.value" closable @close="closeTutorial()">
+			<MkInfo v-if="!store.reactiveState.abusesTutorial.value" closable @close="closeTutorial()">
 				{{ i18n.ts._abuseUserReport.resolveTutorial }}
 			</MkInfo>
 
@@ -68,7 +68,7 @@ import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 const reports = shallowRef<InstanceType<typeof MkPagination>>();
 
@@ -93,7 +93,7 @@ function resolved(reportId) {
 }
 
 function closeTutorial() {
-	defaultStore.set('abusesTutorial', false);
+	store.set('abusesTutorial', false);
 }
 
 const headerActions = computed(() => []);
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
index d6ee8ea49c..6537b844c3 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
@@ -78,6 +78,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import * as Misskey from 'misskey-js';
 import { onMounted, ref, useCssModule } from 'vue';
+import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
+import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
+import type { DroppedFile } from '@/scripts/file-drop.js';
+import type { GridSetting } from '@/components/grid/grid.js';
+import type { GridRow } from '@/components/grid/row.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import {
 	emptyStrToEmptyArray,
@@ -88,7 +93,6 @@ import MkGrid from '@/components/grid/MkGrid.vue';
 import { i18n } from '@/i18n.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { defaultStore } from '@/store.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
@@ -99,11 +103,7 @@ import { extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js
 import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
 
-import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
-import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
-import type { DroppedFile } from '@/scripts/file-drop.js';
-import type { GridSetting } from '@/components/grid/grid.js';
-import type { GridRow } from '@/components/grid/row.js';
+import { prefer } from '@/preferences.js';
 
 const MAXIMUM_EMOJI_REGISTER_COUNT = 100;
 
@@ -244,8 +244,8 @@ function setupGrid(): GridSetting {
 
 const uploadFolders = ref<FolderItem[]>([]);
 const gridItems = ref<GridItem[]>([]);
-const selectedFolderId = ref(defaultStore.state.uploadFolder);
-const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
+const selectedFolderId = ref(prefer.s.uploadFolder);
+const keepOriginalUploading = ref(prefer.s.keepOriginalUploading);
 const directoryToCategory = ref<boolean>(false);
 const registerButtonDisabled = ref<boolean>(false);
 const requestLogs = ref<RequestLogItem[]>([]);
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 79dd6fd5fd..1a72f9050c 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -17,7 +17,7 @@ import { onMounted, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -54,7 +54,7 @@ async function renderChart() {
 
 	const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const colorRead = '#3498db';
 	const colorWrite = '#2ecc71';
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index 570fcddc07..197f792755 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -27,7 +27,7 @@ import isChromatic from 'chromatic';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { alpha } from '@/scripts/color.js';
 import { initChart } from '@/scripts/init-chart.js';
 
@@ -68,7 +68,7 @@ onMounted(async () => {
 
 	const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 	const succColor = '#87e000';
 	const failColor = '#ff4400';
 
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
index 292e2e1dbc..67c0297dec 100644
--- a/packages/frontend/src/pages/admin/overview.instances.vue
+++ b/packages/frontend/src/pages/admin/overview.instances.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div>
-	<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
+	<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
 		<MkLoading v-if="fetching"/>
 		<div v-else :class="$style.instances">
 			<MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance">
@@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref } from 'vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const instances = ref<Misskey.entities.FederationInstance[]>([]);
 const fetching = ref(true);
diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue
index f0691534c8..4d503c0035 100644
--- a/packages/frontend/src/pages/admin/overview.moderators.vue
+++ b/packages/frontend/src/pages/admin/overview.moderators.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div>
-	<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
+	<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
 		<MkLoading v-if="fetching"/>
 		<div v-else :class="$style.root" class="_panel">
 			<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/admin/user/${user.id}`">
@@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
 import * as Misskey from 'misskey-js';
-import { defaultStore } from '@/store.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { prefer } from '@/preferences.js';
 
 const moderators = ref<Misskey.entities.UserDetailed[] | null>(null);
 const fetching = ref(true);
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 2efc17c888..7b83641fbd 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { alpha } from '@/scripts/color.js';
@@ -67,7 +67,7 @@ const color =
 	'?' as never;
 
 onMounted(() => {
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	chartInstance = new Chart(chartEl.value, {
 		type: 'line',
diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue
index 222e9f4673..72b93ee97c 100644
--- a/packages/frontend/src/pages/admin/overview.stats.vue
+++ b/packages/frontend/src/pages/admin/overview.stats.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div>
-	<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
+	<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
 		<MkLoading v-if="fetching"/>
 		<div v-else :class="$style.root">
 			<div class="item _panel users">
@@ -68,7 +68,7 @@ import MkNumberDiff from '@/components/MkNumberDiff.vue';
 import MkNumber from '@/components/MkNumber.vue';
 import { i18n } from '@/i18n.js';
 import { customEmojis } from '@/custom-emojis.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const stats = ref<Misskey.entities.StatsResponse | null>(null);
 const usersComparedToThePrevDay = ref<number>();
diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue
index 8c9d7a8197..8de4f298ce 100644
--- a/packages/frontend/src/pages/admin/overview.users.vue
+++ b/packages/frontend/src/pages/admin/overview.users.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div :class="$style.root">
-	<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
+	<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
 		<MkLoading v-if="fetching"/>
 		<div v-else class="users">
 			<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/admin/user/${user.id}`" class="user">
@@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref } from 'vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null);
 const fetching = ref(true);
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index cc18898172..debb48a15a 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { alpha } from '@/scripts/color.js';
@@ -67,7 +67,7 @@ const color =
 	'?' as never;
 
 onMounted(() => {
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	chartInstance = new Chart(chartEl.value, {
 		type: 'line',
diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue
index 56c10fb292..b8903a8ea6 100644
--- a/packages/frontend/src/pages/announcement.vue
+++ b/packages/frontend/src/pages/announcement.vue
@@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
 		<Transition
-			:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
-			:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
-			:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
-			:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
+			:enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''"
+			:leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''"
+			:enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''"
+			:leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''"
 			mode="out-in"
 		>
 			<div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement">
@@ -56,7 +56,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { $i, updateAccountPartial } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	announcementId: string;
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 4a91165d50..d333204ff8 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
 
 				<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
-				<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
+				<MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
 
 				<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
 			</div>
@@ -75,6 +75,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import type { PageHeaderItem } from '@/types/page-header.js';
 import MkPostForm from '@/components/MkPostForm.vue';
 import MkTimeline from '@/components/MkTimeline.vue';
 import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
@@ -85,16 +87,14 @@ import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import MkNotes from '@/components/MkNotes.vue';
-import { url } from '@@/js/config.js';
 import { favoritedChannelsCache } from '@/cache.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import MkNote from '@/components/MkNote.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import type { PageHeaderItem } from '@/types/page-header.js';
 import { isSupportShare } from '@/scripts/navigator.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import { notesSearchAvailable } from '@/scripts/check-permissions.js';
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index 10099e6291..55c984e1bf 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 
 			<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
-				<img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
+				<img v-if="store.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
 				<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
 				<canvas ref="canvasEl" :class="$style.canvas"/>
 				<Transition
@@ -195,6 +195,8 @@ import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch
 import * as Matter from 'matter-js';
 import * as Misskey from 'misskey-js';
 import { DropAndFusionGame } from 'misskey-bubble-game';
+import { useInterval } from '@@/js/use-interval.js';
+import { apiUrl } from '@@/js/config.js';
 import type { Mono } from 'misskey-bubble-game';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
@@ -203,15 +205,14 @@ import MkNumber from '@/components/MkNumber.vue';
 import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
 import MkButton from '@/components/MkButton.vue';
 import { claimAchievement } from '@/scripts/achievements.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { useInterval } from '@@/js/use-interval.js';
-import { apiUrl } from '@@/js/config.js';
 import { $i } from '@/account.js';
 import * as sound from '@/scripts/sound.js';
 import MkRange from '@/components/MkRange.vue';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { prefer } from '@/preferences.js';
 
 type FrontendMonoDefinition = {
 	id: string;
@@ -586,8 +587,8 @@ const showConfig = ref(false);
 const replaying = ref(false);
 const replayPlaybackRate = ref(1);
 const currentFrame = ref(0);
-const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume);
-const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume);
+const bgmVolume = ref(prefer.s['game.dropAndFusion'].bgmVolume);
+const sfxVolume = ref(prefer.s['game.dropAndFusion'].sfxVolume);
 
 watch(replayPlaybackRate, (newValue) => {
 	game.replayPlaybackRate = newValue;
@@ -623,7 +624,7 @@ function loadMonoTextures() {
 		if (renderer.textures[mono.img]) return;
 
 		let src = mono.img;
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+
 		if (monoTextureUrls[mono.img]) {
 			src = monoTextureUrls[mono.img];
 			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -649,7 +650,6 @@ function loadMonoTextures() {
 function getTextureImageUrl(mono: Mono) {
 	const def = monoDefinitions.value.find(x => x.id === mono.id)!;
 
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 	if (monoTextureUrls[def.img]) {
 		return monoTextureUrls[def.img];
 
@@ -853,13 +853,13 @@ function exportLog() {
 }
 
 function updateSettings<
-	K extends keyof typeof defaultStore.state.dropAndFusion,
-	V extends typeof defaultStore.state.dropAndFusion[K],
+	K extends keyof typeof prefer.s['game.dropAndFusion'],
+	V extends typeof prefer.s['game.dropAndFusion'][K],
 >(key: K, value: V) {
 	const changes: { [P in K]?: V } = {};
 	changes[key] = value;
-	defaultStore.set('dropAndFusion', {
-		...defaultStore.state.dropAndFusion,
+	prefer.set('game.dropAndFusion', {
+		...prefer.s['game.dropAndFusion'],
 		...changes,
 	});
 }
@@ -909,8 +909,8 @@ function getGameImageDriveFile() {
 				formData.append('name', `bubble-game-${Date.now()}.png`);
 				formData.append('isSensitive', 'false');
 				formData.append('i', $i.token);
-				if (defaultStore.state.uploadFolder) {
-					formData.append('folderId', defaultStore.state.uploadFolder);
+				if (prefer.s.uploadFolder) {
+					formData.append('folderId', prefer.s.uploadFolder);
 				}
 
 				window.fetch(apiUrl + '/drive/files/create', {
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 6294a3f4a2..532ec87dac 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700">
-		<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
+		<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
 			<div v-if="flash" :key="flash.id">
-				<Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in">
+				<Transition :name="prefer.s.animation ? 'zoom' : ''" mode="out-in">
 					<div v-if="started" :class="$style.started">
 						<div class="main _panel">
 							<MkAsUi v-if="root" :component="root" :components="components"/>
@@ -79,7 +79,7 @@ import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
 import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkCode from '@/components/MkCode.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
 import { isSupportShare } from '@/scripts/navigator.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index feb4c60611..7e1b7be16c 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32">
 		<div class="_root">
-			<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
+			<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
 				<div v-if="post" class="rkxwuolj">
 					<div class="files">
 						<div v-for="file in post.files" :key="file.id" class="file">
@@ -65,6 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, watch, ref, defineAsyncComponent } from 'vue';
 import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -72,15 +74,13 @@ import MkContainer from '@/components/MkContainer.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
 import MkFollowButton from '@/components/MkFollowButton.vue';
-import { url } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
 import { isSupportShare } from '@/scripts/navigator.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
-import type { MenuItem } from '@/types/menu.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue
index 58f8b865bb..423bd611e6 100644
--- a/packages/frontend/src/pages/install-extensions.vue
+++ b/packages/frontend/src/pages/install-extensions.vue
@@ -45,18 +45,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue';
+import type { Extension } from '@/components/MkExtensionInstaller.vue';
+import type { AiScriptPluginMeta } from '@/plugin.js';
 import MkLoading from '@/components/global/MkLoading.vue';
 import MkExtensionInstaller from '@/components/MkExtensionInstaller.vue';
-import type { Extension } from '@/components/MkExtensionInstaller.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkUrl from '@/components/global/MkUrl.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js';
-import type { AiScriptPluginMeta } from '@/scripts/install-plugin.js';
-import { parseThemeCode, installTheme } from '@/scripts/install-theme.js';
+import { parsePluginMeta, installPlugin } from '@/plugin.js';
+import { parseThemeCode, installTheme } from '@/scripts/theme.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 69e404bd85..acedeeeb7d 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -67,15 +67,15 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkInput from '@/components/MkInput.vue';
 import { userListsCache } from '@/cache.js';
 import { signinRequired } from '@/account.js';
-import { defaultStore } from '@/store.js';
 import MkPagination from '@/components/MkPagination.vue';
 import { mainRouter } from '@/router/main.js';
+import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
 
 const {
 	enableInfiniteScroll,
-} = defaultStore.reactiveState;
+} = prefer.r;
 
 const props = defineProps<{
 	listId: string;
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index 0791c1343b..eaeef2e566 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
 		<div>
-			<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
+			<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
 				<div v-if="note">
 					<div v-if="showNext" class="_margin">
 						<MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/>
@@ -61,7 +61,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { dateString } from '@/filters/date.js';
 import MkClipPreview from '@/components/MkClipPreview.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { serverContext, assertServerContext } from '@/server-context.js';
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index d9ad7babb7..3444ef49aa 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
 		<Transition
-			:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
-			:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
-			:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
-			:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
+			:enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''"
+			:leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''"
+			:enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''"
+			:leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''"
 			mode="out-in"
 		>
 			<div v-if="page" :key="page.id" class="_gaps">
@@ -100,11 +100,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, watch, ref, defineAsyncComponent } from 'vue';
 import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
 import XPage from '@/components/page/page.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { url } from '@@/js/config.js';
 import MkMediaImage from '@/components/MkMediaImage.vue';
 import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
 import MkFollowButton from '@/components/MkFollowButton.vue';
@@ -113,7 +114,7 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkPagePreview from '@/components/MkPagePreview.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { pageViewInterruptors, defaultStore } from '@/store.js';
+import { pageViewInterruptors } from '@/store.js';
 import { deepClone } from '@/scripts/clone.js';
 import { $i } from '@/account.js';
 import { isSupportShare } from '@/scripts/navigator.js';
@@ -121,7 +122,7 @@ import { instance } from '@/instance.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
-import type { MenuItem } from '@/types/menu.js';
+import { prefer } from '@/preferences.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue
index b703be1fe1..f464f728ff 100644
--- a/packages/frontend/src/pages/settings/accessibility.vue
+++ b/packages/frontend/src/pages/settings/accessibility.vue
@@ -8,49 +8,63 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div class="_gaps_m">
 		<div class="_gaps_s">
 			<SearchMarker :keywords="['animation', 'motion', 'reduce']">
-				<MkSwitch v-model="reduceAnimation">
-					<template #label><SearchLabel>{{ i18n.ts.reduceUiAnimation }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="animation">
+					<MkSwitch v-model="reduceAnimation">
+						<template #label><SearchLabel>{{ i18n.ts.reduceUiAnimation }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 
 			<SearchMarker :keywords="['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif']">
-				<MkSwitch v-model="disableShowingAnimatedImages">
-					<template #label><SearchLabel>{{ i18n.ts.disableShowingAnimatedImages }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="disableShowingAnimatedImages">
+					<MkSwitch v-model="disableShowingAnimatedImages">
+						<template #label><SearchLabel>{{ i18n.ts.disableShowingAnimatedImages }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 
 			<SearchMarker :keywords="['mfm', 'enable', 'show', 'animated']">
-				<MkSwitch v-model="animatedMfm">
-					<template #label><SearchLabel>{{ i18n.ts.enableAnimatedMfm }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="animatedMfm">
+					<MkSwitch v-model="animatedMfm">
+						<template #label><SearchLabel>{{ i18n.ts.enableAnimatedMfm }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 
 			<SearchMarker :keywords="['swipe', 'horizontal', 'tab']">
-				<MkSwitch v-model="enableHorizontalSwipe">
-					<template #label><SearchLabel>{{ i18n.ts.enableHorizontalSwipe }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="enableHorizontalSwipe">
+					<MkSwitch v-model="enableHorizontalSwipe">
+						<template #label><SearchLabel>{{ i18n.ts.enableHorizontalSwipe }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 
 			<SearchMarker :keywords="['keep', 'screen', 'display', 'on']">
-				<MkSwitch v-model="keepScreenOn">
-					<template #label><SearchLabel>{{ i18n.ts.keepScreenOn }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="keepScreenOn">
+					<MkSwitch v-model="keepScreenOn">
+						<template #label><SearchLabel>{{ i18n.ts.keepScreenOn }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 
 			<SearchMarker :keywords="['native', 'system', 'video', 'audio', 'player', 'media']">
-				<MkSwitch v-model="useNativeUIForVideoAudioPlayer">
-					<template #label><SearchLabel>{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</SearchLabel></template>
-				</MkSwitch>
+				<MkPreferenceContainer k="useNativeUiForVideoAudioPlayer">
+					<MkSwitch v-model="useNativeUiForVideoAudioPlayer">
+						<template #label><SearchLabel>{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</SearchLabel></template>
+					</MkSwitch>
+				</MkPreferenceContainer>
 			</SearchMarker>
 		</div>
 
 		<SearchMarker :keywords="['contextmenu', 'system', 'native']">
-			<MkSelect v-model="contextMenu">
-				<template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template>
-				<option value="app">{{ i18n.ts._contextMenu.app }}</option>
-				<option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option>
-				<option value="native">{{ i18n.ts._contextMenu.native }}</option>
-			</MkSelect>
+			<MkPreferenceContainer k="contextMenu">
+				<MkSelect v-model="contextMenu">
+					<template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template>
+					<option value="app">{{ i18n.ts._contextMenu.app }}</option>
+					<option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option>
+					<option value="native">{{ i18n.ts._contextMenu.native }}</option>
+				</MkSelect>
+			</MkPreferenceContainer>
 		</SearchMarker>
 	</div>
 </SearchMarker>
@@ -60,18 +74,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref, watch } from 'vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
-const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
-const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm'));
-const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
-const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
-const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
-const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
-const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
+const reduceAnimation = prefer.model('animation', v => !v, v => !v);
+const animatedMfm = prefer.model('animatedMfm');
+const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages');
+const keepScreenOn = prefer.model('keepScreenOn');
+const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
+const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
+const contextMenu = prefer.model('contextMenu');
 
 watch([
 	keepScreenOn,
diff --git a/packages/frontend/src/pages/settings/appearance.vue b/packages/frontend/src/pages/settings/appearance.vue
index 465c2a38c2..b23f32aec4 100644
--- a/packages/frontend/src/pages/settings/appearance.vue
+++ b/packages/frontend/src/pages/settings/appearance.vue
@@ -10,73 +10,85 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div class="_gaps_m">
 				<div class="_gaps_s">
 					<SearchMarker :keywords="['blur']">
-						<MkSwitch v-model="useBlurEffect">
-							<template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="useBlurEffect">
+							<MkSwitch v-model="useBlurEffect">
+								<template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['blur', 'modal']">
-						<MkSwitch v-model="useBlurEffectForModal">
-							<template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="useBlurEffectForModal">
+							<MkSwitch v-model="useBlurEffectForModal">
+								<template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']">
-						<MkSwitch v-model="highlightSensitiveMedia">
-							<template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="highlightSensitiveMedia">
+							<MkSwitch v-model="highlightSensitiveMedia">
+								<template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['avatar', 'icon', 'square']">
-						<MkSwitch v-model="squareAvatars">
-							<template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="squareAvatars">
+							<MkSwitch v-model="squareAvatars">
+								<template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']">
-						<MkSwitch v-model="showAvatarDecorations">
-							<template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="showAvatarDecorations">
+							<MkSwitch v-model="showAvatarDecorations">
+								<template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['note', 'timeline', 'gap']">
-						<MkSwitch v-model="showGapBetweenNotesInTimeline">
-							<template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template>
-						</MkSwitch>
-					</SearchMarker>
-
-					<SearchMarker :keywords="['font', 'system', 'native']">
-						<MkSwitch v-model="useSystemFont">
-							<template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="showGapBetweenNotesInTimeline">
+							<MkSwitch v-model="showGapBetweenNotesInTimeline">
+								<template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['effect', 'show']">
-						<MkSwitch v-model="enableSeasonalScreenEffect">
-							<template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="enableSeasonalScreenEffect">
+							<MkSwitch v-model="enableSeasonalScreenEffect">
+								<template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 				</div>
 
 				<SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']">
-					<MkSelect v-model="menuStyle">
-						<template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template>
-						<option value="auto">{{ i18n.ts.auto }}</option>
-						<option value="popup">{{ i18n.ts.popup }}</option>
-						<option value="drawer">{{ i18n.ts.drawer }}</option>
-					</MkSelect>
+					<MkPreferenceContainer k="menuStyle">
+						<MkSelect v-model="menuStyle">
+							<template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template>
+							<option value="auto">{{ i18n.ts.auto }}</option>
+							<option value="popup">{{ i18n.ts.popup }}</option>
+							<option value="drawer">{{ i18n.ts.drawer }}</option>
+						</MkSelect>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']">
-					<div>
-						<MkRadios v-model="emojiStyle">
-							<template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template>
-							<option value="native">{{ i18n.ts.native }}</option>
-							<option value="fluentEmoji">Fluent Emoji</option>
-							<option value="twemoji">Twemoji</option>
-						</MkRadios>
-						<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
-					</div>
+					<MkPreferenceContainer k="emojiStyle">
+						<div>
+							<MkRadios v-model="emojiStyle">
+								<template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template>
+								<option value="native">{{ i18n.ts.native }}</option>
+								<option value="fluentEmoji">Fluent Emoji</option>
+								<option value="twemoji">Twemoji</option>
+							</MkRadios>
+							<div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
+						</div>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['font', 'size']">
@@ -88,6 +100,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<option value="3"><span style="font-size: 17px;">Aa</span></option>
 					</MkRadios>
 				</SearchMarker>
+
+				<SearchMarker :keywords="['font', 'system', 'native']">
+					<MkSwitch v-model="useSystemFont">
+						<template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template>
+					</MkSwitch>
+				</SearchMarker>
 			</div>
 		</FormSection>
 
@@ -97,46 +115,56 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 				<div class="_gaps_m">
 					<SearchMarker :keywords="['reaction', 'size', 'scale', 'display']">
-						<MkRadios v-model="reactionsDisplaySize">
-							<template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template>
-							<option value="small">{{ i18n.ts.small }}</option>
-							<option value="medium">{{ i18n.ts.medium }}</option>
-							<option value="large">{{ i18n.ts.large }}</option>
-						</MkRadios>
+						<MkPreferenceContainer k="reactionsDisplaySize">
+							<MkRadios v-model="reactionsDisplaySize">
+								<template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template>
+								<option value="small">{{ i18n.ts.small }}</option>
+								<option value="medium">{{ i18n.ts.medium }}</option>
+								<option value="large">{{ i18n.ts.large }}</option>
+							</MkRadios>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']">
-						<MkSwitch v-model="limitWidthOfReaction">
-							<template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="limitWidthOfReaction">
+							<MkSwitch v-model="limitWidthOfReaction">
+								<template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']">
-						<MkRadios v-model="mediaListWithOneImageAppearance">
-							<template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template>
-							<option value="expand">{{ i18n.ts.default }}</option>
-							<option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option>
-							<option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option>
-							<option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option>
-						</MkRadios>
+						<MkPreferenceContainer k="mediaListWithOneImageAppearance">
+							<MkRadios v-model="mediaListWithOneImageAppearance">
+								<template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template>
+								<option value="expand">{{ i18n.ts.default }}</option>
+								<option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option>
+								<option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option>
+								<option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option>
+							</MkRadios>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']">
-						<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
-							<template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template>
-							<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
-							<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
-							<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
-						</MkSelect>
+						<MkPreferenceContainer k="instanceTicker">
+							<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
+								<template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template>
+								<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
+								<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
+								<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
+							</MkSelect>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']">
-						<MkSelect v-model="nsfw">
-							<template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template>
-							<option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option>
-							<option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option>
-							<option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option>
-						</MkSelect>
+						<MkPreferenceContainer k="nsfw">
+							<MkSelect v-model="nsfw">
+								<template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template>
+								<option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option>
+								<option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option>
+								<option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option>
+							</MkSelect>
+						</MkPreferenceContainer>
 					</SearchMarker>
 				</div>
 			</FormSection>
@@ -148,21 +176,25 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 				<div class="_gaps_m">
 					<SearchMarker :keywords="['position']">
-						<MkRadios v-model="notificationPosition">
-							<template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template>
-							<option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
-							<option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option>
-							<option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option>
-							<option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option>
-						</MkRadios>
+						<MkPreferenceContainer k="notificationPosition">
+							<MkRadios v-model="notificationPosition">
+								<template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template>
+								<option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
+								<option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option>
+								<option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option>
+								<option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option>
+							</MkRadios>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['stack', 'axis', 'direction']">
-						<MkRadios v-model="notificationStackAxis">
-							<template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template>
-							<option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option>
-							<option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option>
-						</MkRadios>
+						<MkPreferenceContainer k="notificationStackAxis">
+							<MkRadios v-model="notificationStackAxis">
+								<template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template>
+								<option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option>
+								<option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option>
+							</MkRadios>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton>
@@ -183,7 +215,7 @@ import * as Misskey from 'misskey-js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkRadios from '@/components/MkRadios.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -194,26 +226,27 @@ import { claimAchievement } from '@/scripts/achievements.js';
 import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
 import { instance } from '@/instance.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
 const fontSize = ref(miLocalStorage.getItem('fontSize'));
 const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
 
-const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations'));
-const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
-const menuStyle = computed(defaultStore.makeGetterSetter('menuStyle'));
-const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
-const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
-const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
-const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
-const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
-const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
-const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
-const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize'));
-const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction'));
-const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
-const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
-const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
-const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
+const showAvatarDecorations = prefer.model('showAvatarDecorations');
+const emojiStyle = prefer.model('emojiStyle');
+const menuStyle = prefer.model('menuStyle');
+const useBlurEffectForModal = prefer.model('useBlurEffectForModal');
+const useBlurEffect = prefer.model('useBlurEffect');
+const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia');
+const squareAvatars = prefer.model('squareAvatars');
+const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect');
+const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline');
+const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance');
+const reactionsDisplaySize = prefer.model('reactionsDisplaySize');
+const limitWidthOfReaction = prefer.model('limitWidthOfReaction');
+const notificationPosition = prefer.model('notificationPosition');
+const notificationStackAxis = prefer.model('notificationStackAxis');
+const nsfw = prefer.model('nsfw');
+const instanceTicker = prefer.model('instanceTicker');
 
 watch(fontSize, () => {
 	if (fontSize.value == null) {
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
index e574ec7dc0..e1965b6d36 100644
--- a/packages/frontend/src/pages/settings/deck.vue
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -23,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkRadios from '@/components/MkRadios.vue';
-import { deckStore } from '@/ui/deck/deck-store.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { prefer } from '@/preferences.js';
 
-const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
-const useSimpleUiForNonRootPages = computed(deckStore.makeGetterSetter('useSimpleUiForNonRootPages'));
-const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
-const columnAlign = computed(deckStore.makeGetterSetter('columnAlign'));
+const navWindow = prefer.model('deck.navWindow');
+const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages');
+const alwaysShowMainColumn = prefer.model('deck.alwaysShowMainColumn');
+const columnAlign = prefer.model('deck.columnAlign');
 
 const headerActions = computed(() => []);
 
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 0138aac1c5..8e61b11dbe 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -50,17 +50,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</FormLink>
 
 				<SearchMarker :keywords="['keep', 'original', 'raw', 'upload']">
-					<MkSwitch v-model="keepOriginalUploading">
-						<template #label><SearchLabel>{{ i18n.ts.keepOriginalUploading }}</SearchLabel></template>
-						<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalUploadingDescription }}</SearchKeyword></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="keepOriginalUploading">
+						<MkSwitch v-model="keepOriginalUploading">
+							<template #label><SearchLabel>{{ i18n.ts.keepOriginalUploading }}</SearchLabel></template>
+							<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalUploadingDescription }}</SearchKeyword></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['keep', 'original', 'filename']">
-					<MkSwitch v-model="keepOriginalFilename">
-						<template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template>
-						<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="keepOriginalFilename">
+						<MkSwitch v-model="keepOriginalFilename">
+							<template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template>
+							<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']">
@@ -93,11 +97,12 @@ import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import bytes from '@/filters/bytes.js';
-import { defaultStore } from '@/store.js';
 import MkChart from '@/components/MkChart.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { signinRequired } from '@/account.js';
+import { prefer } from '@/preferences.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
 const $i = signinRequired();
 
@@ -120,8 +125,8 @@ const meterStyle = computed(() => {
 	};
 });
 
-const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
-const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename'));
+const keepOriginalUploading = prefer.model('keepOriginalUploading');
+const keepOriginalFilename = prefer.model('keepOriginalFilename');
 
 misskeyApi('drive').then(info => {
 	capacity.value = info.capacity;
@@ -129,9 +134,9 @@ misskeyApi('drive').then(info => {
 	fetching.value = false;
 });
 
-if (defaultStore.state.uploadFolder) {
+if (prefer.s.uploadFolder) {
 	misskeyApi('drive/folders/show', {
-		folderId: defaultStore.state.uploadFolder,
+		folderId: prefer.s.uploadFolder,
 	}).then(response => {
 		uploadFolder.value = response;
 	});
@@ -139,11 +144,11 @@ if (defaultStore.state.uploadFolder) {
 
 function chooseUploadFolder() {
 	os.selectDriveFolder(false).then(async folder => {
-		defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null);
+		prefer.set('uploadFolder', folder[0] ? folder[0].id : null);
 		os.success();
-		if (defaultStore.state.uploadFolder) {
+		if (prefer.s.uploadFolder) {
 			uploadFolder.value = await misskeyApi('drive/folders/show', {
-				folderId: defaultStore.state.uploadFolder,
+				folderId: prefer.s.uploadFolder,
 			});
 		} else {
 			uploadFolder.value = null;
diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue
index b16c943676..d2060a9112 100644
--- a/packages/frontend/src/pages/settings/emoji-picker.vue
+++ b/packages/frontend/src/pages/settings/emoji-picker.vue
@@ -89,37 +89,45 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
 
 		<div class="_gaps_m">
-			<MkRadios v-model="emojiPickerScale">
-				<template #label>{{ i18n.ts.size }}</template>
-				<option :value="1">{{ i18n.ts.small }}</option>
-				<option :value="2">{{ i18n.ts.medium }}</option>
-				<option :value="3">{{ i18n.ts.large }}</option>
-			</MkRadios>
-
-			<MkRadios v-model="emojiPickerWidth">
-				<template #label>{{ i18n.ts.numberOfColumn }}</template>
-				<option :value="1">5</option>
-				<option :value="2">6</option>
-				<option :value="3">7</option>
-				<option :value="4">8</option>
-				<option :value="5">9</option>
-			</MkRadios>
-
-			<MkRadios v-model="emojiPickerHeight">
-				<template #label>{{ i18n.ts.height }}</template>
-				<option :value="1">{{ i18n.ts.small }}</option>
-				<option :value="2">{{ i18n.ts.medium }}</option>
-				<option :value="3">{{ i18n.ts.large }}</option>
-				<option :value="4">{{ i18n.ts.large }}+</option>
-			</MkRadios>
-
-			<MkSelect v-model="emojiPickerStyle">
-				<template #label>{{ i18n.ts.style }}</template>
-				<template #caption>{{ i18n.ts.needReloadToApply }}</template>
-				<option value="auto">{{ i18n.ts.auto }}</option>
-				<option value="popup">{{ i18n.ts.popup }}</option>
-				<option value="drawer">{{ i18n.ts.drawer }}</option>
-			</MkSelect>
+			<MkPreferenceContainer k="emojiPickerScale">
+				<MkRadios v-model="emojiPickerScale">
+					<template #label>{{ i18n.ts.size }}</template>
+					<option :value="1">{{ i18n.ts.small }}</option>
+					<option :value="2">{{ i18n.ts.medium }}</option>
+					<option :value="3">{{ i18n.ts.large }}</option>
+				</MkRadios>
+			</MkPreferenceContainer>
+
+			<MkPreferenceContainer k="emojiPickerWidth">
+				<MkRadios v-model="emojiPickerWidth">
+					<template #label>{{ i18n.ts.numberOfColumn }}</template>
+					<option :value="1">5</option>
+					<option :value="2">6</option>
+					<option :value="3">7</option>
+					<option :value="4">8</option>
+					<option :value="5">9</option>
+				</MkRadios>
+			</MkPreferenceContainer>
+
+			<MkPreferenceContainer k="emojiPickerHeight">
+				<MkRadios v-model="emojiPickerHeight">
+					<template #label>{{ i18n.ts.height }}</template>
+					<option :value="1">{{ i18n.ts.small }}</option>
+					<option :value="2">{{ i18n.ts.medium }}</option>
+					<option :value="3">{{ i18n.ts.large }}</option>
+					<option :value="4">{{ i18n.ts.large }}+</option>
+				</MkRadios>
+			</MkPreferenceContainer>
+
+			<MkPreferenceContainer k="emojiPickerStyle">
+				<MkSelect v-model="emojiPickerStyle">
+					<template #label>{{ i18n.ts.style }}</template>
+					<template #caption>{{ i18n.ts.needReloadToApply }}</template>
+					<option value="auto">{{ i18n.ts.auto }}</option>
+					<option value="popup">{{ i18n.ts.popup }}</option>
+					<option value="drawer">{{ i18n.ts.drawer }}</option>
+				</MkSelect>
+			</MkPreferenceContainer>
 		</div>
 	</FormSection>
 </div>
@@ -127,14 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, ref, watch } from 'vue';
-import type { Ref } from 'vue';
 import Sortable from 'vuedraggable';
+import type { Ref } from 'vue';
 import MkRadios from '@/components/MkRadios.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { deepClone } from '@/scripts/clone.js';
@@ -143,14 +151,16 @@ import { emojiPicker } from '@/scripts/emoji-picker.js';
 import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
 import MkEmoji from '@/components/global/MkEmoji.vue';
 import MkFolder from '@/components/MkFolder.vue';
+import { prefer } from '@/preferences.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
-const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions));
-const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis));
+const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(store.state.reactions));
+const pinnedEmojis: Ref<string[]> = ref(deepClone(store.state.pinnedEmojis));
 
-const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale'));
-const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth'));
-const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight'));
-const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle'));
+const emojiPickerScale = prefer.model('emojiPickerScale');
+const emojiPickerWidth = prefer.model('emojiPickerWidth');
+const emojiPickerHeight = prefer.model('emojiPickerHeight');
+const emojiPickerStyle = prefer.model('emojiPickerStyle');
 
 const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
 const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
@@ -210,7 +220,7 @@ async function setDefault(itemsRef: Ref<string[]>) {
 	});
 	if (canceled) return;
 
-	itemsRef.value = deepClone(defaultStore.def.reactions.default);
+	itemsRef.value = deepClone(store.def.reactions.default);
 }
 
 async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
@@ -230,13 +240,13 @@ function getHTMLElement(ev: MouseEvent): HTMLElement {
 }
 
 watch(pinnedEmojisForReaction, () => {
-	defaultStore.set('reactions', pinnedEmojisForReaction.value);
+	store.set('reactions', pinnedEmojisForReaction.value);
 }, {
 	deep: true,
 });
 
 watch(pinnedEmojis, () => {
-	defaultStore.set('pinnedEmojis', pinnedEmojis.value);
+	store.set('pinnedEmojis', pinnedEmojis.value);
 }, {
 	deep: true,
 });
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index 6b67a9a1a8..949f9019d9 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -155,11 +155,11 @@ import { selectFile } from '@/scripts/select-file.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { $i } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 const excludeMutingUsers = ref(false);
 const excludeInactiveUsers = ref(false);
-const withReplies = ref(defaultStore.state.defaultWithReplies);
+const withReplies = ref(store.state.defaultWithReplies);
 
 const onExportSuccess = () => {
 	os.alert({
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 458605d545..6203e7f698 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -12,6 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div v-if="!narrow || currentPage?.route.name == null" class="nav">
 					<div class="baaadecd">
 						<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+						<MkInfo v-if="!store.reactiveState.enablePreferencesAutoCloudBackup.value && store.reactiveState.showPreferencesAutoCloudBackupSuggestion.value" class="info">
+							<div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div>
+							<div><button class="_textButton" @click="enableAutoBackup">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipAutoBackup">{{ i18n.ts.skip }}</button></div>
+						</MkInfo>
 						<MkSuperMenu :def="menuDef" :grid="narrow" :searchIndex="SETTING_INDEX"></MkSuperMenu>
 					</div>
 				</div>
@@ -41,6 +45,8 @@ import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata }
 import * as os from '@/os.js';
 import { useRouter } from '@/router/supplier.js';
 import { searchIndexes } from '@/scripts/autogen/settings-search-index.js';
+import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js';
+import { store } from '@/store.js';
 
 const SETTING_INDEX = searchIndexes; // TODO: lazy load
 
@@ -65,6 +71,10 @@ const ro = new ResizeObserver((entries, observer) => {
 	narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
 });
 
+function skipAutoBackup() {
+	store.set('showPreferencesAutoCloudBackupSuggestion', false);
+}
+
 const menuDef = computed<SuperMenuDef[]>(() => [{
 	items: [{
 		icon: 'ti ti-user',
@@ -168,10 +178,12 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
 	}],
 }, {
 	items: [{
-		icon: 'ti ti-device-floppy',
-		text: i18n.ts.preferencesBackups,
-		to: '/settings/preferences-backups',
-		active: currentPage.value?.route.name === 'preferences-backups',
+		type: 'button',
+		icon: 'ti ti-settings-2',
+		text: i18n.ts.preferencesProfile,
+		action: async (ev: MouseEvent) => {
+			os.popupMenu(getPreferencesProfileMenu(), ev.currentTarget ?? ev.target);
+		},
 	}, {
 		type: 'button',
 		icon: 'ti ti-trash',
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index 4aac2a25bd..3620c05ca8 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -186,8 +186,8 @@ import { signinRequired } from '@/account.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { defaultStore } from '@/store';
 import { reloadAsk } from '@/scripts/reload-ask.js';
+import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
 
@@ -210,7 +210,7 @@ const expandedRenoteMuteItems = ref([]);
 const expandedMuteItems = ref([]);
 const expandedBlockItems = ref([]);
 
-const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord'));
+const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord');
 
 watch([
 	showSoftWordMutedWord,
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index c38cdc4fc2..5e1dedd709 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -53,22 +53,24 @@ import FormSlot from '@/components/form/slot.vue';
 import MkContainer from '@/components/MkContainer.vue';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { prefer } from '@/preferences.js';
+import { PREF_DEF } from '@/preferences/def.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
-const items = ref(defaultStore.state.menu.map(x => ({
+const items = ref(prefer.s.menu.map(x => ({
 	id: Math.random().toString(),
 	type: x,
 })));
 
-const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
 
 async function addItem() {
-	const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k));
+	const menu = Object.keys(navbarItemDef).filter(k => !prefer.s.menu.includes(k));
 	const { canceled, result: item } = await os.select({
 		title: i18n.ts.addItem,
 		items: [...menu.map(k => ({
@@ -89,12 +91,12 @@ function removeItem(index: number) {
 }
 
 async function save() {
-	defaultStore.set('menu', items.value.map(x => x.type));
+	prefer.set('menu', items.value.map(x => x.type));
 	await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
 }
 
 function reset() {
-	items.value = defaultStore.def.menu.default.map(x => ({
+	items.value = PREF_DEF.menu.default.map(x => ({
 		id: Math.random().toString(),
 		type: x,
 	}));
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 9742c548e7..21133d72e7 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -117,20 +117,21 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { signout, signinRequired } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
 import FormSection from '@/components/form/section.vue';
+import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
 
-const reportError = computed(defaultStore.makeGetterSetter('reportError'));
-const enableCondensedLine = computed(defaultStore.makeGetterSetter('enableCondensedLine'));
-const skipNoteRender = computed(defaultStore.makeGetterSetter('skipNoteRender'));
-const devMode = computed(defaultStore.makeGetterSetter('devMode'));
-const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
+const reportError = prefer.model('reportError');
+const enableCondensedLine = prefer.model('enableCondensedLine');
+const skipNoteRender = prefer.model('skipNoteRender');
+const devMode = prefer.model('devMode');
+const defaultWithReplies = computed(store.makeGetterSetter('defaultWithReplies'));
 
 watch(skipNoteRender, async () => {
 	await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
index 3ab26e80d9..c79aec91c0 100644
--- a/packages/frontend/src/pages/settings/plugin.install.vue
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -23,10 +23,10 @@ import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
-import { installPlugin } from '@/scripts/install-plugin.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { installPlugin } from '@/plugin.js';
 
 const code = ref<string | null>(null);
 
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index 3c3dcfe41e..fe57812a1d 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -4,72 +4,92 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="_gaps_m">
-	<FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.manage }}</template>
-		<div class="_gaps_s">
-			<div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_m" style="padding: 20px;">
-				<div class="_gaps_s">
-					<span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
-					<MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
-				</div>
-
-				<div class="_gaps_s">
-					<MkKeyValue>
-						<template #key>{{ i18n.ts.author }}</template>
-						<template #value>{{ plugin.author }}</template>
-					</MkKeyValue>
-					<MkKeyValue>
-						<template #key>{{ i18n.ts.description }}</template>
-						<template #value>{{ plugin.description }}</template>
-					</MkKeyValue>
-					<MkKeyValue>
-						<template #key>{{ i18n.ts.permission }}</template>
-						<template #value>
-							<ul style="margin-top: 0; margin-bottom: 0;">
-								<li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
-								<li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li>
-							</ul>
-						</template>
-					</MkKeyValue>
-				</div>
-
-				<div class="_buttons">
-					<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
-					<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
-				</div>
-
-				<MkFolder>
-					<template #icon><i class="ti ti-terminal-2"></i></template>
-					<template #label>{{ i18n.ts._plugin.viewLog }}</template>
-
-					<div class="_gaps_s">
+<SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin']" icon="ti ti-plug">
+	<div class="_gaps_m">
+		<FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
+
+		<FormSection>
+			<template #label>{{ i18n.ts.manage }}</template>
+			<div class="_gaps_s">
+				<MkFolder v-for="plugin in plugins" :key="plugin.installId">
+					<template #icon><i class="ti ti-plug"></i></template>
+					<template #suffix>
+						<i v-if="plugin.active" class="ti ti-player-play" style="color: var(--MI_THEME-accent);"></i>
+						<i v-else class="ti ti-player-pause" style="opacity: 0.7;"></i>
+					</template>
+					<template #label>
+						<div :style="plugin.active ? '' : 'opacity: 0.7;'">
+							{{ plugin.name }}
+							<span style="margin-left: 1em; opacity: 0.7;">v{{ plugin.version }}</span>
+						</div>
+					</template>
+					<template #caption>
+						{{ plugin.description }}
+					</template>
+					<template #footer>
 						<div class="_buttons">
-							<MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+							<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
+							<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
 						</div>
+					</template>
 
-						<MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/>
-					</div>
-				</MkFolder>
-
-				<MkFolder>
-					<template #icon><i class="ti ti-code"></i></template>
-					<template #label>{{ i18n.ts._plugin.viewSource }}</template>
+					<div class="_gaps_m">
+						<div class="_gaps_s">
+							<span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
+							<MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
+						</div>
 
-					<div class="_gaps_s">
-						<div class="_buttons">
-							<MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+						<div class="_gaps_s">
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.author }}</template>
+								<template #value>{{ plugin.author }}</template>
+							</MkKeyValue>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.description }}</template>
+								<template #value>{{ plugin.description }}</template>
+							</MkKeyValue>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.permission }}</template>
+								<template #value>
+									<ul style="margin-top: 0; margin-bottom: 0;">
+										<li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
+										<li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li>
+									</ul>
+								</template>
+							</MkKeyValue>
 						</div>
 
-						<MkCode :code="plugin.src ?? ''" lang="is"/>
+						<MkFolder>
+							<template #icon><i class="ti ti-terminal-2"></i></template>
+							<template #label>{{ i18n.ts._plugin.viewLog }}</template>
+
+							<div class="_gaps_s">
+								<div class="_buttons">
+									<MkButton inline @click="copy(pluginLogs.get(plugin.installId)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+								</div>
+
+								<MkCode :code="pluginLogs.get(plugin.installId)?.join('\n') ?? ''"/>
+							</div>
+						</MkFolder>
+
+						<MkFolder>
+							<template #icon><i class="ti ti-code"></i></template>
+							<template #label>{{ i18n.ts._plugin.viewSource }}</template>
+
+							<div class="_gaps_s">
+								<div class="_buttons">
+									<MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+								</div>
+
+								<MkCode :code="plugin.src ?? ''" lang="ais"/>
+							</div>
+						</MkFolder>
 					</div>
 				</MkFolder>
 			</div>
-		</div>
-	</FormSection>
-</div>
+		</FormSection>
+	</div>
+</SearchMarker>
 </template>
 
 <script lang="ts" setup>
@@ -83,19 +103,16 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { ColdDeviceStorage } from '@/store.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { pluginLogs } from '@/plugin.js';
+import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin } from '@/plugin.js';
+import { prefer } from '@/preferences.js';
 
-const plugins = ref(ColdDeviceStorage.get('plugins'));
+const plugins = prefer.r.plugins;
 
 async function uninstall(plugin) {
-	ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
-	await os.apiWithDialog('i/revoke-token', {
-		token: plugin.token,
-	});
+	await uninstallPlugin(plugin);
 	nextTick(() => {
 		unisonReload();
 	});
@@ -106,30 +123,15 @@ function copy(text) {
 	os.success();
 }
 
-// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
 async function config(plugin) {
-	const config = plugin.config;
-	for (const key in plugin.configData) {
-		config[key].default = plugin.configData[key];
-	}
-
-	const { canceled, result } = await os.form(plugin.name, config);
-	if (canceled) return;
-
-	const coldPlugins = ColdDeviceStorage.get('plugins');
-	coldPlugins.find(p => p.id === plugin.id)!.configData = result;
-	ColdDeviceStorage.set('plugins', coldPlugins);
-
+	await configPlugin(plugin);
 	nextTick(() => {
 		location.reload();
 	});
 }
 
 function changeActive(plugin, active) {
-	const coldPlugins = ColdDeviceStorage.get('plugins');
-	coldPlugins.find(p => p.id === plugin.id)!.active = active;
-	ColdDeviceStorage.set('plugins', coldPlugins);
-
+	changePluginActive(plugin, active);
 	nextTick(() => {
 		location.reload();
 	});
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
deleted file mode 100644
index 7388e014ed..0000000000
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ /dev/null
@@ -1,465 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<div class="_gaps_m">
-	<div :class="$style.buttons">
-		<MkButton inline primary @click="saveNew">{{ i18n.ts._preferencesBackups.saveNew }}</MkButton>
-		<MkButton inline @click="loadFile">{{ i18n.ts._preferencesBackups.loadFile }}</MkButton>
-	</div>
-
-	<FormSection>
-		<template #label>{{ i18n.ts._preferencesBackups.list }}</template>
-		<template v-if="profiles && Object.keys(profiles).length > 0">
-			<div class="_gaps_s">
-				<div
-					v-for="(profile, id) in profiles"
-					:key="id"
-					class="_panel"
-					:class="$style.profile"
-					@click="$event => menu($event, id)"
-					@contextmenu.prevent.stop="$event => menu($event, id)"
-				>
-					<div :class="$style.profileName">{{ profile.name }}</div>
-					<div :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.createdAt({ date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div>
-					<div v-if="profile.updatedAt" :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.updatedAt({ date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div>
-				</div>
-			</div>
-		</template>
-		<div v-else-if="profiles">
-			<MkInfo>{{ i18n.ts._preferencesBackups.noBackups }}</MkInfo>
-		</div>
-		<MkLoading v-else/>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
-import { v4 as uuid } from 'uuid';
-import { version, host } from '@@/js/config.js';
-import FormSection from '@/components/form/section.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInfo from '@/components/MkInfo.vue';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { ColdDeviceStorage, defaultStore } from '@/store.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
-import { useStream } from '@/stream.js';
-import { $i } from '@/account.js';
-import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { miLocalStorage } from '@/local-storage.js';
-
-const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
-	'collapseRenotes',
-	'menu',
-	'visibility',
-	'localOnly',
-	'statusbars',
-	'widgets',
-	'tl',
-	'pinnedUserLists',
-	'overridedDeviceKind',
-	'serverDisconnectedBehavior',
-	'nsfw',
-	'highlightSensitiveMedia',
-	'animation',
-	'animatedMfm',
-	'advancedMfm',
-	'showReactionsCount',
-	'loadRawImages',
-	'imageNewTab',
-	'dataSaver',
-	'disableShowingAnimatedImages',
-	'emojiStyle',
-	'menuStyle',
-	'useBlurEffectForModal',
-	'useBlurEffect',
-	'showFixedPostForm',
-	'showFixedPostFormInChannel',
-	'enableInfiniteScroll',
-	'useReactionPickerForContextMenu',
-	'showGapBetweenNotesInTimeline',
-	'instanceTicker',
-	'emojiPickerScale',
-	'emojiPickerWidth',
-	'emojiPickerHeight',
-	'emojiPickerStyle',
-	'defaultSideView',
-	'menuDisplay',
-	'reportError',
-	'squareAvatars',
-	'showAvatarDecorations',
-	'numberOfPageCache',
-	'showNoteActionsOnlyHover',
-	'showClipButtonInNoteFooter',
-	'reactionsDisplaySize',
-	'forceShowAds',
-	'aiChanMode',
-	'devMode',
-	'mediaListWithOneImageAppearance',
-	'notificationPosition',
-	'notificationStackAxis',
-	'keepScreenOn',
-	'defaultWithReplies',
-	'disableStreamingTimeline',
-	'useGroupedNotifications',
-	'sound_masterVolume',
-	'sound_note',
-	'sound_noteMy',
-	'sound_notification',
-];
-const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
-	'lightTheme',
-	'darkTheme',
-	'syncDeviceDarkMode',
-	'plugins',
-];
-
-const scope = ['clientPreferencesProfiles'];
-
-const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings', 'host'];
-
-type Profile = {
-	name: string;
-	createdAt: string;
-	updatedAt: string | null;
-	misskeyVersion: string;
-	host: string;
-	settings: {
-		hot: Record<keyof typeof defaultStoreSaveKeys, unknown>;
-		cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
-		fontSize: string | null;
-		useSystemFont: 't' | null;
-		wallpaper: string | null;
-	};
-};
-
-const connection = $i && useStream().useChannel('main');
-
-const profiles = ref<Record<string, Profile> | null>(null);
-
-misskeyApi('i/registry/get-all', { scope })
-	.then(res => {
-		profiles.value = res || {};
-	});
-
-function isObject(value: unknown): value is Record<string, unknown> {
-	return value != null && typeof value === 'object' && !Array.isArray(value);
-}
-
-function validate(profile: any): void {
-	if (!isObject(profile)) throw new Error('not an object');
-
-	// Check if unnecessary properties exist
-	if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist');
-
-	if (!profile.name) throw new Error('Missing required prop: name');
-	if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion');
-
-	// Check if createdAt and updatedAt is Date
-	// https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date
-	if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt as any).getTime())) throw new Error('createdAt is falsy or not Date');
-	if (profile.updatedAt) {
-		if (Number.isNaN(new Date(profile.updatedAt as any).getTime())) {
-			throw new Error('updatedAt is not Date');
-		}
-	} else if (profile.updatedAt !== null) {
-		throw new Error('updatedAt is not null');
-	}
-
-	if (!profile.settings) throw new Error('Missing required prop: settings');
-	if (!isObject(profile.settings)) throw new Error('Invalid prop: settings');
-}
-
-function getSettings(): Profile['settings'] {
-	const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>;
-	for (const key of defaultStoreSaveKeys) {
-		hot[key] = defaultStore.state[key];
-	}
-
-	const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
-	for (const key of coldDeviceStorageSaveKeys) {
-		cold[key] = ColdDeviceStorage.get(key);
-	}
-
-	return {
-		hot,
-		cold,
-		fontSize: miLocalStorage.getItem('fontSize'),
-		useSystemFont: miLocalStorage.getItem('useSystemFont') as 't' | null,
-		wallpaper: miLocalStorage.getItem('wallpaper'),
-	};
-}
-
-async function saveNew(): Promise<void> {
-	if (!profiles.value) return;
-
-	const { canceled, result: name } = await os.inputText({
-		title: i18n.ts._preferencesBackups.inputName,
-		default: '',
-	});
-	if (canceled) return;
-
-	if (Object.values(profiles.value).some(x => x.name === name)) {
-		return os.alert({
-			title: i18n.ts._preferencesBackups.cannotSave,
-			text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }),
-		});
-	}
-
-	const id = uuid();
-	const profile: Profile = {
-		name,
-		createdAt: (new Date()).toISOString(),
-		updatedAt: null,
-		misskeyVersion: version,
-		host,
-		settings: getSettings(),
-	};
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-}
-
-function loadFile(): void {
-	const input = document.createElement('input');
-	input.type = 'file';
-	input.multiple = false;
-	input.onchange = async () => {
-		if (!profiles.value) return;
-		if (!input.files || input.files.length === 0) return;
-
-		const file = input.files[0];
-
-		if (file.type !== 'application/json') {
-			return os.alert({
-				type: 'error',
-				title: i18n.ts._preferencesBackups.cannotLoad,
-				text: i18n.ts._preferencesBackups.invalidFile,
-			});
-		}
-
-		let profile: Profile;
-		try {
-			profile = JSON.parse(await file.text()) as unknown as Profile;
-			validate(profile);
-		} catch (err) {
-			return os.alert({
-				type: 'error',
-				title: i18n.ts._preferencesBackups.cannotLoad,
-				text: (err as any)?.message ?? '',
-			});
-		}
-
-		const id = uuid();
-		await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-
-		// 一応廃棄
-		(window as any).__misskey_input_ref__ = null;
-	};
-
-	// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
-	// iOS Safari で正常に動かす為のおまじない
-	(window as any).__misskey_input_ref__ = input;
-
-	input.click();
-}
-
-async function applyProfile(id: string): Promise<void> {
-	if (!profiles.value) return;
-
-	const profile = profiles.value[id];
-
-	const { canceled: cancel1 } = await os.confirm({
-		type: 'warning',
-		title: i18n.ts._preferencesBackups.apply,
-		text: i18n.tsx._preferencesBackups.applyConfirm({ name: profile.name }),
-	});
-	if (cancel1) return;
-
-	// TODO: バージョン or ホストが違ったらさらに警告を表示
-
-	const settings = profile.settings;
-
-	// defaultStore
-	for (const key of defaultStoreSaveKeys) {
-		if (settings.hot[key] !== undefined) {
-			defaultStore.set(key, settings.hot[key]);
-		}
-	}
-
-	// coldDeviceStorage
-	for (const key of coldDeviceStorageSaveKeys) {
-		if (settings.cold[key] !== undefined) {
-			ColdDeviceStorage.set(key, settings.cold[key]);
-		}
-	}
-
-	// fontSize
-	if (settings.fontSize) {
-		miLocalStorage.setItem('fontSize', settings.fontSize);
-	} else {
-		miLocalStorage.removeItem('fontSize');
-	}
-
-	// useSystemFont
-	if (settings.useSystemFont) {
-		miLocalStorage.setItem('useSystemFont', settings.useSystemFont);
-	} else {
-		miLocalStorage.removeItem('useSystemFont');
-	}
-
-	// wallpaper
-	if (settings.wallpaper != null) {
-		miLocalStorage.setItem('wallpaper', settings.wallpaper);
-	} else {
-		miLocalStorage.removeItem('wallpaper');
-	}
-
-	const { canceled: cancel2 } = await os.confirm({
-		type: 'info',
-		text: i18n.ts.reloadToApplySetting,
-	});
-	if (cancel2) return;
-
-	unisonReload();
-}
-
-async function deleteProfile(id: string): Promise<void> {
-	if (!profiles.value) return;
-
-	const { canceled } = await os.confirm({
-		type: 'info',
-		title: i18n.ts.delete,
-		text: i18n.tsx.deleteAreYouSure({ x: profiles.value[id].name }),
-	});
-	if (canceled) return;
-
-	await os.apiWithDialog('i/registry/remove', { scope, key: id });
-	delete profiles.value[id];
-}
-
-async function save(id: string): Promise<void> {
-	if (!profiles.value) return;
-
-	const { name, createdAt } = profiles.value[id];
-
-	const { canceled } = await os.confirm({
-		type: 'info',
-		title: i18n.ts._preferencesBackups.save,
-		text: i18n.tsx._preferencesBackups.saveConfirm({ name }),
-	});
-	if (canceled) return;
-
-	const profile: Profile = {
-		name,
-		createdAt,
-		updatedAt: (new Date()).toISOString(),
-		misskeyVersion: version,
-		host,
-		settings: getSettings(),
-	};
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
-}
-
-async function rename(id: string): Promise<void> {
-	if (!profiles.value) return;
-
-	const { canceled: cancel1, result: name } = await os.inputText({
-		title: i18n.ts._preferencesBackups.inputName,
-		default: '',
-	});
-	if (cancel1 || profiles.value[id].name === name) return;
-
-	if (Object.values(profiles.value).some(x => x.name === name)) {
-		return os.alert({
-			title: i18n.ts._preferencesBackups.cannotSave,
-			text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }),
-		});
-	}
-
-	const registry = Object.assign({}, { ...profiles.value[id] });
-
-	const { canceled: cancel2 } = await os.confirm({
-		type: 'info',
-		title: i18n.ts.rename,
-		text: i18n.tsx._preferencesBackups.renameConfirm({ old: registry.name, new: name }),
-	});
-	if (cancel2) return;
-
-	registry.name = name;
-	await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry });
-}
-
-function menu(ev: MouseEvent, profileId: string) {
-	if (!profiles.value) return;
-
-	return os.popupMenu([{
-		text: i18n.ts._preferencesBackups.apply,
-		icon: 'ti ti-check',
-		action: () => applyProfile(profileId),
-	}, {
-		type: 'a',
-		text: i18n.ts.download,
-		icon: 'ti ti-download',
-		href: URL.createObjectURL(new Blob([JSON.stringify(profiles.value[profileId], null, 2)], { type: 'application/json' })),
-		download: `${profiles.value[profileId].name}.json`,
-	}, { type: 'divider' }, {
-		text: i18n.ts.rename,
-		icon: 'ti ti-forms',
-		action: () => rename(profileId),
-	}, {
-		text: i18n.ts._preferencesBackups.save,
-		icon: 'ti ti-device-floppy',
-		action: () => save(profileId),
-	}, { type: 'divider' }, {
-		text: i18n.ts.delete,
-		icon: 'ti ti-trash',
-		action: () => deleteProfile(profileId),
-		danger: true,
-	}], (ev.currentTarget ?? ev.target ?? undefined) as unknown as HTMLElement | undefined);
-}
-
-onMounted(() => {
-	// streamingのuser storage updateイベントを監視して更新
-	connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => {
-		if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return;
-		if (!profiles.value) return;
-
-		profiles.value[key] = value;
-	});
-});
-
-onUnmounted(() => {
-	connection?.off('registryUpdated');
-});
-
-definePageMetadata(() => ({
-	title: i18n.ts.preferencesBackups,
-	icon: 'ti ti-device-floppy',
-}));
-</script>
-
-<style lang="scss" module>
-.buttons {
-	display: flex;
-	gap: var(--MI-margin);
-	flex-wrap: wrap;
-}
-
-.profile {
-	padding: 20px;
-	cursor: pointer;
-
-	&Name {
-		font-weight: 700;
-	}
-
-	&Time {
-		font-size: .85em;
-		opacity: .7;
-	}
-}
-</style>
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index fe718bfa69..2df621eaa6 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -33,30 +33,36 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<FormSection>
 			<div class="_gaps_s">
 				<SearchMarker :keywords="['post', 'form', 'timeline']">
-					<MkSwitch v-model="showFixedPostForm">
-						<template #label><SearchLabel>{{ i18n.ts.showFixedPostForm }}</SearchLabel></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="showFixedPostForm">
+						<MkSwitch v-model="showFixedPostForm">
+							<template #label><SearchLabel>{{ i18n.ts.showFixedPostForm }}</SearchLabel></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['post', 'form', 'timeline', 'channel']">
-					<MkSwitch v-model="showFixedPostFormInChannel">
-						<template #label><SearchLabel>{{ i18n.ts.showFixedPostFormInChannel }}</SearchLabel></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="showFixedPostFormInChannel">
+						<MkSwitch v-model="showFixedPostFormInChannel">
+							<template #label><SearchLabel>{{ i18n.ts.showFixedPostFormInChannel }}</SearchLabel></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['pinned', 'list']">
 					<MkFolder>
 						<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
 						<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
-						<MkButton v-if="defaultStore.reactiveState.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton>
+						<MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton>
 						<MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
 					</MkFolder>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']">
-					<MkSwitch v-model="enableQuickAddMfmFunction">
-						<template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="enableQuickAddMfmFunction">
+						<MkSwitch v-model="enableQuickAddMfmFunction">
+							<template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 			</div>
 		</FormSection>
@@ -68,40 +74,52 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div class="_gaps_m">
 					<div class="_gaps_s">
 						<SearchMarker :keywords="['renote']">
-							<MkSwitch v-model="collapseRenotes">
-								<template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template>
-								<template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="collapseRenotes">
+								<MkSwitch v-model="collapseRenotes">
+									<template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template>
+									<template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['hover', 'show', 'footer', 'action']">
-							<MkSwitch v-model="showNoteActionsOnlyHover">
-								<template #label><SearchLabel>{{ i18n.ts.showNoteActionsOnlyHover }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="showNoteActionsOnlyHover">
+								<MkSwitch v-model="showNoteActionsOnlyHover">
+									<template #label><SearchLabel>{{ i18n.ts.showNoteActionsOnlyHover }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['footer', 'action', 'clip', 'show']">
-							<MkSwitch v-model="showClipButtonInNoteFooter">
-								<template #label><SearchLabel>{{ i18n.ts.showClipButtonInNoteFooter }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="showClipButtonInNoteFooter">
+								<MkSwitch v-model="showClipButtonInNoteFooter">
+									<template #label><SearchLabel>{{ i18n.ts.showClipButtonInNoteFooter }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced']">
-							<MkSwitch v-model="advancedMfm">
-								<template #label><SearchLabel>{{ i18n.ts.enableAdvancedMfm }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="advancedMfm">
+								<MkSwitch v-model="advancedMfm">
+									<template #label><SearchLabel>{{ i18n.ts.enableAdvancedMfm }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['reaction', 'count', 'show']">
-							<MkSwitch v-model="showReactionsCount">
-								<template #label><SearchLabel>{{ i18n.ts.showReactionsCount }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="showReactionsCount">
+								<MkSwitch v-model="showReactionsCount">
+									<template #label><SearchLabel>{{ i18n.ts.showReactionsCount }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment']">
-							<MkSwitch v-model="loadRawImages">
-								<template #label><SearchLabel>{{ i18n.ts.loadRawImages }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="loadRawImages">
+								<MkSwitch v-model="loadRawImages">
+									<template #label><SearchLabel>{{ i18n.ts.loadRawImages }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 					</div>
 				</div>
@@ -114,9 +132,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 				<div class="_gaps_m">
 					<SearchMarker :keywords="['group']">
-						<MkSwitch v-model="useGroupedNotifications">
-							<template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="useGroupedNotifications">
+							<MkSwitch v-model="useGroupedNotifications">
+								<template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 				</div>
 			</FormSection>
@@ -129,62 +149,88 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div class="_gaps_m">
 					<div class="_gaps_s">
 						<SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']">
-							<MkSwitch v-model="imageNewTab">
-								<template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="imageNewTab">
+								<MkSwitch v-model="imageNewTab">
+									<template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['reaction', 'picker', 'contextmenu', 'open']">
-							<MkSwitch v-model="useReactionPickerForContextMenu">
-								<template #label><SearchLabel>{{ i18n.ts.useReactionPickerForContextMenu }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="useReactionPickerForContextMenu">
+								<MkSwitch v-model="useReactionPickerForContextMenu">
+									<template #label><SearchLabel>{{ i18n.ts.useReactionPickerForContextMenu }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['load', 'auto', 'more']">
-							<MkSwitch v-model="enableInfiniteScroll">
-								<template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="enableInfiniteScroll">
+								<MkSwitch v-model="enableInfiniteScroll">
+									<template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['disable', 'streaming', 'timeline']">
-							<MkSwitch v-model="disableStreamingTimeline">
-								<template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="disableStreamingTimeline">
+								<MkSwitch v-model="disableStreamingTimeline">
+									<template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['follow', 'confirm', 'always']">
-							<MkSwitch v-model="alwaysConfirmFollow">
-								<template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="alwaysConfirmFollow">
+								<MkSwitch v-model="alwaysConfirmFollow">
+									<template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']">
-							<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">
-								<template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="confirmWhenRevealingSensitiveMedia">
+								<MkSwitch v-model="confirmWhenRevealingSensitiveMedia">
+									<template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['reaction', 'confirm']">
-							<MkSwitch v-model="confirmOnReact">
-								<template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template>
-							</MkSwitch>
+							<MkPreferenceContainer k="confirmOnReact">
+								<MkSwitch v-model="confirmOnReact">
+									<template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
+						</SearchMarker>
+
+						<SearchMarker :keywords="['remember', 'keep', 'note', 'cw']">
+							<MkPreferenceContainer k="keepCw">
+								<MkSwitch v-model="keepCw">
+									<template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template>
+								</MkSwitch>
+							</MkPreferenceContainer>
 						</SearchMarker>
 					</div>
 
 					<SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']">
-						<MkSelect v-model="serverDisconnectedBehavior">
-							<template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template>
-							<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
-							<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
-							<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
-						</MkSelect>
+						<MkPreferenceContainer k="serverDisconnectedBehavior">
+							<MkSelect v-model="serverDisconnectedBehavior">
+								<template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template>
+								<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
+								<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
+								<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
+							</MkSelect>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['cache', 'page']">
-						<MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing>
-							<template #label><SearchLabel>{{ i18n.ts.numberOfPageCache }}</SearchLabel></template>
-							<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
-						</MkRange>
+						<MkPreferenceContainer k="numberOfPageCache">
+							<MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing>
+								<template #label><SearchLabel>{{ i18n.ts.numberOfPageCache }}</SearchLabel></template>
+								<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
+							</MkRange>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :label="i18n.ts.dataSaver" :keywords="['datasaver']">
@@ -229,18 +275,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 				<div class="_gaps">
 					<SearchMarker :keywords="['ad', 'show']">
-						<MkSwitch v-model="forceShowAds">
-							<template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template>
-						</MkSwitch>
+						<MkPreferenceContainer k="forceShowAds">
+							<MkSwitch v-model="forceShowAds">
+								<template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template>
+							</MkSwitch>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker>
-						<MkRadios v-model="hemisphere">
-							<template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template>
-							<option value="N">{{ i18n.ts._hemisphere.N }}</option>
-							<option value="S">{{ i18n.ts._hemisphere.S }}</option>
-							<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
-						</MkRadios>
+						<MkPreferenceContainer k="hemisphere">
+							<MkRadios v-model="hemisphere">
+								<template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template>
+								<option value="N">{{ i18n.ts._hemisphere.N }}</option>
+								<option value="S">{{ i18n.ts._hemisphere.S }}</option>
+								<template #caption>{{ i18n.ts._hemisphere.caption }}</template>
+							</MkRadios>
+						</MkPreferenceContainer>
 					</SearchMarker>
 
 					<SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']">
@@ -248,8 +298,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template>
 							<div class="_buttons">
 								<template v-for="lang in emojiIndexLangs" :key="lang">
-									<MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton>
-									<MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton>
+									<MkButton v-if="store.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton>
+									<MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ store.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton>
 								</template>
 							</div>
 						</MkFolder>
@@ -272,7 +322,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, ref, watch } from 'vue';
-import * as Misskey from 'misskey-js';
 import { langs } from '@@/js/config.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
@@ -284,41 +333,44 @@ import FormSection from '@/components/form/section.vue';
 import FormLink from '@/components/form/link.vue';
 import MkLink from '@/components/MkLink.vue';
 import MkInfo from '@/components/MkInfo.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
-import { instance } from '@/instance.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
+import { prefer } from '@/preferences.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
 const lang = ref(miLocalStorage.getItem('lang'));
-const dataSaver = ref(defaultStore.state.dataSaver);
-
-const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere'));
-const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
-const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
-const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover'));
-const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter'));
-const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
-const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm'));
-const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount'));
-const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction'));
-const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
-const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
-const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
-const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
-const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
-const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
-const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
-const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
-const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
-const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
-const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
-const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia'));
-const confirmOnReact = computed(defaultStore.makeGetterSetter('confirmOnReact'));
-const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
+const dataSaver = ref(prefer.s.dataSaver);
+
+const overridedDeviceKind = computed(store.makeGetterSetter('overridedDeviceKind'));
+
+const keepCw = prefer.model('keepCw');
+const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior');
+const hemisphere = prefer.model('hemisphere');
+const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover');
+const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter');
+const collapseRenotes = prefer.model('collapseRenotes');
+const advancedMfm = prefer.model('advancedMfm');
+const showReactionsCount = prefer.model('showReactionsCount');
+const enableQuickAddMfmFunction = prefer.model('enableQuickAddMfmFunction');
+const forceShowAds = prefer.model('forceShowAds');
+const loadRawImages = prefer.model('loadRawImages');
+const imageNewTab = prefer.model('imageNewTab');
+const showFixedPostForm = prefer.model('showFixedPostForm');
+const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel');
+const numberOfPageCache = prefer.model('numberOfPageCache');
+const enableInfiniteScroll = prefer.model('enableInfiniteScroll');
+const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu');
+const disableStreamingTimeline = prefer.model('disableStreamingTimeline');
+const useGroupedNotifications = prefer.model('useGroupedNotifications');
+const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow');
+const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia');
+const confirmOnReact = prefer.model('confirmOnReact');
+const contextMenu = prefer.model('contextMenu');
 
 watch(lang, () => {
 	miLocalStorage.setItem('lang', lang.value as string);
@@ -356,7 +408,7 @@ function getEmojiIndexLangName(targetLang: typeof emojiIndexLangs[number]) {
 
 function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) {
 	async function main() {
-		const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
+		const currentIndexes = store.state.additionalUnicodeEmojiIndexes;
 
 		function download() {
 			switch (lang) {
@@ -368,7 +420,7 @@ function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) {
 		}
 
 		currentIndexes[lang] = await download();
-		await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
+		await store.set('additionalUnicodeEmojiIndexes', currentIndexes);
 	}
 
 	os.promiseDialog(main());
@@ -376,9 +428,9 @@ function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) {
 
 function removeEmojiIndex(lang: string) {
 	async function main() {
-		const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
+		const currentIndexes = store.state.additionalUnicodeEmojiIndexes;
 		delete currentIndexes[lang];
-		await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
+		await store.set('additionalUnicodeEmojiIndexes', currentIndexes);
 	}
 
 	os.promiseDialog(main());
@@ -393,16 +445,17 @@ async function setPinnedList() {
 		})),
 	});
 	if (canceled) return;
+	if (list == null) return;
 
-	defaultStore.set('pinnedUserLists', [list]);
+	prefer.set('pinnedUserLists', [list]);
 }
 
 function removePinnedList() {
-	defaultStore.set('pinnedUserLists', []);
+	prefer.set('pinnedUserLists', []);
 }
 
 function enableAllDataSaver() {
-	const g = { ...defaultStore.state.dataSaver };
+	const g = { ...prefer.s.dataSaver };
 
 	Object.keys(g).forEach((key) => { g[key] = true; });
 
@@ -410,7 +463,7 @@ function enableAllDataSaver() {
 }
 
 function disableAllDataSaver() {
-	const g = { ...defaultStore.state.dataSaver };
+	const g = { ...prefer.s.dataSaver };
 
 	Object.keys(g).forEach((key) => { g[key] = false; });
 
@@ -418,7 +471,7 @@ function disableAllDataSaver() {
 }
 
 watch(dataSaver, (to) => {
-	defaultStore.set('dataSaver', to);
+	prefer.set('dataSaver', to);
 }, {
 	deep: true,
 });
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index cd0d54a73b..792b4147da 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -172,38 +172,41 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<FormSection>
 			<div class="_gaps_m">
 				<SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']">
-					<MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">
-						<template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template>
-					</MkSwitch>
+					<MkPreferenceContainer k="rememberNoteVisibility">
+						<MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">
+							<template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template>
+						</MkSwitch>
+					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['default', 'note', 'visibility']">
-					<MkFolder v-if="!rememberNoteVisibility">
-						<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
-						<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
-						<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
-						<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
-						<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
-
-						<div class="_gaps_m">
-							<MkSelect v-model="defaultNoteVisibility">
-								<option value="public">{{ i18n.ts._visibility.public }}</option>
-								<option value="home">{{ i18n.ts._visibility.home }}</option>
-								<option value="followers">{{ i18n.ts._visibility.followers }}</option>
-								<option value="specified">{{ i18n.ts._visibility.specified }}</option>
-							</MkSelect>
-							<MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch>
-						</div>
-					</MkFolder>
+					<MkDisableSection :disabled="rememberNoteVisibility">
+						<MkFolder>
+							<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
+							<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
+							<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
+							<template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
+							<template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
+
+							<div class="_gaps_m">
+								<MkPreferenceContainer k="defaultNoteVisibility">
+									<MkSelect v-model="defaultNoteVisibility">
+										<option value="public">{{ i18n.ts._visibility.public }}</option>
+										<option value="home">{{ i18n.ts._visibility.home }}</option>
+										<option value="followers">{{ i18n.ts._visibility.followers }}</option>
+										<option value="specified">{{ i18n.ts._visibility.specified }}</option>
+									</MkSelect>
+								</MkPreferenceContainer>
+
+								<MkPreferenceContainer k="defaultNoteLocalOnly">
+									<MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch>
+								</MkPreferenceContainer>
+							</div>
+						</MkFolder>
+					</MkDisableSection>
 				</SearchMarker>
 			</div>
 		</FormSection>
-
-		<SearchMarker :keywords="['remember', 'keep', 'note', 'cw']">
-			<MkSwitch v-model="keepCw" @update:modelValue="save()">
-				<template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template>
-			</MkSwitch>
-		</SearchMarker>
 	</div>
 </SearchMarker>
 </template>
@@ -215,7 +218,6 @@ import MkSelect from '@/components/MkSelect.vue';
 import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { signinRequired } from '@/account.js';
@@ -225,6 +227,8 @@ import { formatDateTimeString } from '@/scripts/format-time-string.js';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
 import MkDisableSection from '@/components/MkDisableSection.vue';
+import { prefer } from '@/preferences.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
 const $i = signinRequired();
 
@@ -241,10 +245,9 @@ const publicReactions = ref($i.publicReactions);
 const followingVisibility = ref($i.followingVisibility);
 const followersVisibility = ref($i.followersVisibility);
 
-const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility'));
-const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly'));
-const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
-const keepCw = computed(defaultStore.makeGetterSetter('keepCw'));
+const defaultNoteVisibility = prefer.model('defaultNoteVisibility');
+const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly');
+const rememberNoteVisibility = prefer.model('rememberNoteVisibility');
 
 const makeNotesFollowersOnlyBefore_type = computed(() => {
 	if (makeNotesFollowersOnlyBefore.value == null) {
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 51148a1f72..f9ddbbc9ed 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -168,7 +168,7 @@ import { signinRequired } from '@/account.js';
 import { langmap } from '@/scripts/langmap.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { claimAchievement } from '@/scripts/achievements.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { globalEvents } from '@/events.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -177,7 +177,7 @@ const $i = signinRequired();
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
-const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
+const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance'));
 
 function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
 	return lang != null && lang in langmap;
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 1df2d89277..808ae06f41 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -7,21 +7,27 @@ SPDX-License-Identifier: AGPL-3.0-only
 <SearchMarker path="/settings/sounds" :label="i18n.ts.sounds" :keywords="['sounds']" icon="ti ti-music">
 	<div class="_gaps_m">
 		<SearchMarker :keywords="['mute']">
-			<MkSwitch v-model="notUseSound">
-				<template #label><SearchLabel>{{ i18n.ts.notUseSound }}</SearchLabel></template>
-			</MkSwitch>
+			<MkPreferenceContainer k="sound.notUseSound">
+				<MkSwitch v-model="notUseSound">
+					<template #label><SearchLabel>{{ i18n.ts.notUseSound }}</SearchLabel></template>
+				</MkSwitch>
+			</MkPreferenceContainer>
 		</SearchMarker>
 
 		<SearchMarker :keywords="['active', 'mute']">
-			<MkSwitch v-model="useSoundOnlyWhenActive">
-				<template #label><SearchLabel>{{ i18n.ts.useSoundOnlyWhenActive }}</SearchLabel></template>
-			</MkSwitch>
+			<MkPreferenceContainer k="sound.useSoundOnlyWhenActive">
+				<MkSwitch v-model="useSoundOnlyWhenActive">
+					<template #label><SearchLabel>{{ i18n.ts.useSoundOnlyWhenActive }}</SearchLabel></template>
+				</MkSwitch>
+			</MkPreferenceContainer>
 		</SearchMarker>
 
 		<SearchMarker :keywords="['volume', 'master']">
-			<MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
-				<template #label><SearchLabel>{{ i18n.ts.masterVolume }}</SearchLabel></template>
-			</MkRange>
+			<MkPreferenceContainer k="sound.masterVolume">
+				<MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
+					<template #label><SearchLabel>{{ i18n.ts.masterVolume }}</SearchLabel></template>
+				</MkRange>
+			</MkPreferenceContainer>
 		</SearchMarker>
 
 		<FormSection>
@@ -52,7 +58,8 @@ import { computed, ref } from 'vue';
 import XSound from './sounds.sound.vue';
 import type { Ref } from 'vue';
 import type { SoundType, OperationType } from '@/scripts/sound.js';
-import type { SoundStore } from '@/store.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { prefer } from '@/preferences.js';
 import MkRange from '@/components/MkRange.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
@@ -60,18 +67,19 @@ import MkFolder from '@/components/MkFolder.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { operationTypes } from '@/scripts/sound.js';
-import { defaultStore } from '@/store.js';
 import MkSwitch from '@/components/MkSwitch.vue';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
+import { PREF_DEF } from '@/preferences/def.js';
 
-const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound'));
-const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive'));
-const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume'));
+const notUseSound = prefer.model('sound.notUseSound');
+const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive');
+const masterVolume = prefer.model('sound.masterVolume');
 
 const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
-	note: defaultStore.reactiveState.sound_note,
-	noteMy: defaultStore.reactiveState.sound_noteMy,
-	notification: defaultStore.reactiveState.sound_notification,
-	reaction: defaultStore.reactiveState.sound_reaction,
+	note: prefer.r['sound.on.note'],
+	noteMy: prefer.r['sound.on.noteMy'],
+	notification: prefer.r['sound.on.notification'],
+	reaction: prefer.r['sound.on.reaction'],
 });
 
 function getSoundTypeName(f: SoundType): string {
@@ -93,14 +101,14 @@ async function updated(type: keyof typeof sounds.value, sound) {
 		volume: sound.volume,
 	};
 
-	defaultStore.set(`sound_${type}`, v);
+	prefer.set(`sound.on.${type}`, v);
 	sounds.value[type] = v;
 }
 
 function reset() {
 	for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
-		const v = defaultStore.def[`sound_${sound}`].default;
-		defaultStore.set(`sound_${sound}`, v);
+		const v = PREF_DEF[`sound.on.${sound}`].default;
+		prefer.set(`sound.on.${sound}`, v);
 		sounds.value[sound] = v;
 	}
 }
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index 140b6beb14..ede395e51e 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -94,17 +94,17 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { deepClone } from '@/scripts/clone.js';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	_id: string;
 	userLists: Misskey.entities.UserList[] | null;
 }>();
 
-const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id)));
+const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id)));
 
 watch(() => statusbar.type, () => {
 	if (statusbar.type === 'rss') {
@@ -134,13 +134,13 @@ watch(() => statusbar.type, () => {
 watch(statusbar, save);
 
 async function save() {
-	const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
-	const statusbars = deepClone(defaultStore.state.statusbars);
+	const i = prefer.s.statusbars.findIndex(x => x.id === props._id);
+	const statusbars = deepClone(prefer.s.statusbars);
 	statusbars[i] = deepClone(statusbar);
-	defaultStore.set('statusbars', statusbars);
+	prefer.set('statusbars', statusbars);
 }
 
 function del() {
-	defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
+	prefer.set('statusbars', prefer.s.statusbars.filter(x => x.id !== props._id));
 }
 </script>
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
index 1ae3de7994..068d28bc4e 100644
--- a/packages/frontend/src/pages/settings/statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -22,11 +22,11 @@ import XStatusbar from './statusbar.statusbar.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkButton from '@/components/MkButton.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { prefer } from '@/preferences.js';
 
-const statusbars = defaultStore.reactiveState.statusbars;
+const statusbars = prefer.r.statusbars;
 
 const userLists = ref<Misskey.entities.UserList[] | null>(null);
 
@@ -37,13 +37,13 @@ onMounted(() => {
 });
 
 async function add() {
-	defaultStore.push('statusbars', {
+	prefer.set('statusbars', [...statusbars.value, {
 		id: uuid(),
 		type: null,
 		black: false,
 		size: 'medium',
 		props: {},
-	});
+	}]);
 }
 
 const headerActions = computed(() => []);
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
index 4f05d3784c..b19b12aaab 100644
--- a/packages/frontend/src/pages/settings/theme.install.vue
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed } from 'vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
-import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
+import { parseThemeCode, previewTheme, installTheme } from '@/scripts/theme.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
index b0e4ce13d5..41de2aa6a6 100644
--- a/packages/frontend/src/pages/settings/theme.vue
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -75,6 +75,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, onActivated, ref, watch } from 'vue';
 import JSON5 from 'json5';
+import defaultLightTheme from '@@/themes/l-light.json5';
+import defaultDarkTheme from '@@/themes/d-green-lime.json5';
 import type { MkSelectItem } from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
@@ -84,15 +86,15 @@ import MkButton from '@/components/MkButton.vue';
 import { getBuiltinThemesRef } from '@/scripts/theme.js';
 import { selectFile } from '@/scripts/select-file.js';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
-import { ColdDeviceStorage, defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { uniqueBy } from '@/scripts/array.js';
-import { fetchThemes, getThemes } from '@/theme-store.js';
+import { getThemes } from '@/theme-store.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { reloadAsk } from '@/scripts/reload-ask.js';
-import * as os from '@/os.js';
+import { prefer } from '@/preferences.js';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
@@ -169,39 +171,39 @@ const darkThemeSelectorItems = computed(() => {
 	return items;
 });
 
-const darkTheme = ColdDeviceStorage.ref('darkTheme');
+const darkTheme = prefer.r.darkTheme;
 const darkThemeId = computed({
 	get() {
-		return darkTheme.value.id;
+		return darkTheme.value ? darkTheme.value.id : defaultDarkTheme.id;
 	},
 	set(id) {
 		const t = themes.value.find(x => x.id === id);
 		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
-			ColdDeviceStorage.set('darkTheme', t);
+			prefer.set('darkTheme', t);
 		}
 	},
 });
-const lightTheme = ColdDeviceStorage.ref('lightTheme');
+const lightTheme = prefer.r.lightTheme;
 const lightThemeId = computed({
 	get() {
-		return lightTheme.value.id;
+		return lightTheme.value ? lightTheme.value.id : defaultLightTheme.id;
 	},
 	set(id) {
 		const t = themes.value.find(x => x.id === id);
 		if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
-			ColdDeviceStorage.set('lightTheme', t);
+			prefer.set('lightTheme', t);
 		}
 	},
 });
 
-const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
-const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
+const darkMode = computed(store.makeGetterSetter('darkMode'));
+const syncDeviceDarkMode = prefer.model('syncDeviceDarkMode');
 const wallpaper = ref(miLocalStorage.getItem('wallpaper'));
 const themesCount = installedThemes.value.length;
 
 watch(syncDeviceDarkMode, () => {
 	if (syncDeviceDarkMode.value) {
-		defaultStore.set('darkMode', isDeviceDarkmode());
+		store.set('darkMode', isDeviceDarkmode());
 	}
 });
 
@@ -215,12 +217,6 @@ watch(wallpaper, async () => {
 });
 
 onActivated(() => {
-	fetchThemes().then(() => {
-		installedThemes.value = getThemes();
-	});
-});
-
-fetchThemes().then(() => {
 	installedThemes.value = getThemes();
 });
 
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
index b669e25179..5fc921c55c 100644
--- a/packages/frontend/src/pages/tag.vue
+++ b/packages/frontend/src/pages/tag.vue
@@ -26,7 +26,7 @@ import MkButton from '@/components/MkButton.vue';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { genEmbedCode } from '@/scripts/get-embed-code.js';
 
@@ -44,11 +44,11 @@ const pagination = {
 const notes = ref<InstanceType<typeof MkNotes>>();
 
 async function post() {
-	defaultStore.set('postFormHashtags', props.tag);
-	defaultStore.set('postFormWithHashtags', true);
+	store.set('postFormHashtags', props.tag);
+	store.set('postFormWithHashtags', true);
 	await os.post();
-	defaultStore.set('postFormHashtags', '');
-	defaultStore.set('postFormWithHashtags', false);
+	store.set('postFormHashtags', '');
+	store.set('postFormWithHashtags', false);
 	notes.value?.pagingComponent?.reload();
 }
 
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index 76567cc403..c701030f9e 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -78,24 +78,23 @@ import { toUnicode } from 'punycode.js';
 import tinycolor from 'tinycolor2';
 import { v4 as uuid } from 'uuid';
 import JSON5 from 'json5';
-
 import lightTheme from '@@/themes/_light.json5';
 import darkTheme from '@@/themes/_dark.json5';
+import { host } from '@@/js/config.js';
+import type { Theme } from '@/scripts/theme.js';
 import MkButton from '@/components/MkButton.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkFolder from '@/components/MkFolder.vue';
-
 import { $i } from '@/account.js';
 import { applyTheme } from '@/scripts/theme.js';
-import type { Theme } from '@/scripts/theme.js';
-import { host } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { ColdDeviceStorage, defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { addTheme } from '@/theme-store.js';
 import { i18n } from '@/i18n.js';
 import { useLeaveGuard } from '@/scripts/use-leave-guard.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { prefer } from '@/preferences.js';
 
 const bgColors = [
 	{ color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
@@ -201,10 +200,10 @@ async function saveAs() {
 	if (description.value) theme.value.desc = description.value;
 	await addTheme(theme.value);
 	applyTheme(theme.value);
-	if (defaultStore.state.darkMode) {
-		ColdDeviceStorage.set('darkTheme', theme.value);
+	if (store.state.darkMode) {
+		prefer.set('darkTheme', theme.value);
 	} else {
-		ColdDeviceStorage.set('lightTheme', theme.value);
+		prefer.set('lightTheme', theme.value);
 	}
 	changed.value = false;
 	os.alert({
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 044a1908ab..4c15194672 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -9,10 +9,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkSpacer :contentMax="800">
 		<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
 			<div :key="src" ref="rootEl">
-				<MkInfo v-if="isBasicTimeline(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()">
+				<MkInfo v-if="isBasicTimeline(src) && !store.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()">
 					{{ i18n.ts._timelineDescription[src] }}
 				</MkInfo>
-				<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--MI-margin);"/>
+				<MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--MI-margin);"/>
 				<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
 				<div :class="$style.tl">
 					<MkTimeline
@@ -36,25 +36,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue';
+import { scroll } from '@@/js/scroll.js';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
+import type { MenuItem } from '@/types/menu.js';
+import type { BasicTimelineType } from '@/timelines.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { scroll } from '@@/js/scroll.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import { deepMerge } from '@/scripts/merge.js';
-import type { MenuItem } from '@/types/menu.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
-import type { BasicTimelineType } from '@/timelines.js';
+import { prefer } from '@/preferences.js';
 
 provide('shouldOmitHeaderTitle', true);
 
@@ -66,18 +67,18 @@ type TimelinePageSrc = BasicTimelineType | `list:${string}`;
 const queue = ref(0);
 const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global');
 const src = computed<TimelinePageSrc>({
-	get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
+	get: () => ($i ? store.reactiveState.tl.value.src : srcWhenNotSignin.value),
 	set: (x) => saveSrc(x),
 });
 const withRenotes = computed<boolean>({
-	get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
+	get: () => store.reactiveState.tl.value.filter.withRenotes,
 	set: (x) => saveTlFilter('withRenotes', x),
 });
 
 // computed内での無限ループを防ぐためのフラグ
 const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>(
-	defaultStore.reactiveState.tl.value.filter.withReplies ? 'withReplies' :
-	defaultStore.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' :
+	store.reactiveState.tl.value.filter.withReplies ? 'withReplies' :
+	store.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' :
 	false,
 );
 
@@ -87,7 +88,7 @@ const withReplies = computed<boolean>({
 		if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') {
 			return false;
 		} else {
-			return defaultStore.reactiveState.tl.value.filter.withReplies;
+			return store.reactiveState.tl.value.filter.withReplies;
 		}
 	},
 	set: (x) => saveTlFilter('withReplies', x),
@@ -97,7 +98,7 @@ const onlyFiles = computed<boolean>({
 		if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') {
 			return false;
 		} else {
-			return defaultStore.reactiveState.tl.value.filter.onlyFiles;
+			return store.reactiveState.tl.value.filter.onlyFiles;
 		}
 	},
 	set: (x) => saveTlFilter('onlyFiles', x),
@@ -114,7 +115,7 @@ watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => {
 });
 
 const withSensitive = computed<boolean>({
-	get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
+	get: () => store.reactiveState.tl.value.filter.withSensitive,
 	set: (x) => saveTlFilter('withSensitive', x),
 });
 
@@ -195,23 +196,23 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
 }
 
 function saveSrc(newSrc: TimelinePageSrc): void {
-	const out = deepMerge({ src: newSrc }, defaultStore.state.tl);
+	const out = deepMerge({ src: newSrc }, store.state.tl);
 
 	if (newSrc.startsWith('userList:')) {
 		const id = newSrc.substring('userList:'.length);
-		out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null;
+		out.userList = prefer.r.pinnedUserLists.value.find(l => l.id === id) ?? null;
 	}
 
-	defaultStore.set('tl', out);
+	store.set('tl', out);
 	if (['local', 'global'].includes(newSrc)) {
 		srcWhenNotSignin.value = newSrc as 'local' | 'global';
 	}
 }
 
-function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
+function saveTlFilter(key: keyof typeof store.state.tl.filter, newValue: boolean) {
 	if (key !== 'withReplies' || $i) {
-		const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl);
-		defaultStore.set('tl', out);
+		const out = deepMerge({ filter: { [key]: newValue } }, store.state.tl);
+		store.set('tl', out);
 	}
 }
 
@@ -230,9 +231,9 @@ function focus(): void {
 
 function closeTutorial(): void {
 	if (!isBasicTimeline(src.value)) return;
-	const before = defaultStore.state.timelineTutorials;
+	const before = store.state.timelineTutorials;
 	before[src.value] = true;
-	defaultStore.set('timelineTutorials', before);
+	store.set('timelineTutorials', before);
 }
 
 function switchTlIfNeeded() {
@@ -298,7 +299,7 @@ const headerActions = computed(() => {
 	return tmp;
 });
 
-const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({
+const headerTabs = computed(() => [...(prefer.r.pinnedUserLists.value.map(l => ({
 	key: 'list:' + l.id,
 	title: l.name,
 	icon: 'ti ti-star',
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index 7b74ea67ca..64d20f7945 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -20,7 +20,7 @@ import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -64,7 +64,7 @@ async function renderChart() {
 
 	const raw = await misskeyApi('charts/user/following', { userId: props.user.id, limit: chartLimit, span: 'day' });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const colorFollowLocal = '#008FFB';
 	const colorFollowRemote = '#008FFB88';
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index 8c7484ae08..cb171928eb 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -20,7 +20,7 @@ import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -64,7 +64,7 @@ async function renderChart() {
 
 	const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const colorNormal = '#008FFB';
 	const colorReply = '#FEB019';
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index a073626cbb..2804211f95 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -20,7 +20,7 @@ import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 import { chartVLine } from '@/scripts/chart-vline.js';
 import { initChart } from '@/scripts/init-chart.js';
@@ -64,7 +64,7 @@ async function renderChart() {
 
 	const raw = await misskeyApi('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' });
 
-	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+	const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
 	const colorUser = '#3498db';
 	const colorVisitor = '#2ecc71';
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 8ebcf975b7..932d944dde 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -176,7 +176,6 @@ import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
 import { $i, iAmModerator } from '@/account.js';
 import { dateString } from '@/filters/date.js';
 import { confetti } from '@/scripts/confetti.js';
@@ -185,6 +184,7 @@ import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFf
 import { useRouter } from '@/router/supplier.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 import MkSparkle from '@/components/MkSparkle.vue';
+import { prefer } from '@/preferences.js';
 
 function calcAge(birthdate: string): number {
 	const date = new Date(birthdate);
@@ -236,7 +236,7 @@ watch(moderationNote, async () => {
 
 const style = computed(() => {
 	if (props.user.bannerUrl == null) return {};
-	if (defaultStore.state.disableShowingAnimatedImages) {
+	if (prefer.s.disableShowingAnimatedImages) {
 		return {
 			backgroundImage: `url(${ getStaticImageUrl(props.user.bannerUrl) })`,
 		};
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts
index 918b81b204..778d5c1981 100644
--- a/packages/frontend/src/pizzax.ts
+++ b/packages/frontend/src/pizzax.ts
@@ -11,7 +11,7 @@ import type { Ref } from 'vue';
 import { $i } from '@/account.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { get, set } from '@/scripts/idb-proxy.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { useStream } from '@/stream.js';
 import { deepClone } from '@/scripts/clone.js';
 import { deepMerge } from '@/scripts/merge.js';
@@ -148,7 +148,7 @@ export class Storage<T extends StateDef> {
 			if ($i) {
 				// api関数と循環参照なので一応setTimeoutしておく
 				window.setTimeout(async () => {
-					await defaultStore.ready;
+					await store.ready;
 
 					misskeyApi('i/registry/get-all', { scope: ['client', this.key] })
 						.then(kvs => {
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index e319a8c398..609c819053 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -3,39 +3,208 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { ref } from 'vue';
+import { ref, defineAsyncComponent } from 'vue';
 import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
+import { compareVersions } from 'compare-versions';
+import { v4 as uuid } from 'uuid';
 import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
-import { inputText } from '@/os.js';
-import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js';
-import type { Plugin } from '@/store.js';
+import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors, store } from '@/store.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+
+export type Plugin = {
+	installId: string;
+	name: string;
+	active: boolean;
+	config?: Record<string, { default: any }>;
+	configData: Record<string, any>;
+	src: string | null;
+	version: string;
+	author?: string;
+	description?: string;
+	permissions?: string[];
+};
+
+export type AiScriptPluginMeta = {
+	name: string;
+	version: string;
+	author: string;
+	description?: string;
+	permissions?: string[];
+	config?: Record<string, any>;
+};
 
 const parser = new Parser();
+
+export function isSupportedAiScriptVersion(version: string): boolean {
+	try {
+		return (compareVersions(version, '0.12.0') >= 0);
+	} catch (err) {
+		return false;
+	}
+}
+
+export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> {
+	if (!code) {
+		throw new Error('code is required');
+	}
+
+	const lv = utils.getLangVersion(code);
+	if (lv == null) {
+		throw new Error('No language version annotation found');
+	} else if (!isSupportedAiScriptVersion(lv)) {
+		throw new Error(`Aiscript version '${lv}' is not supported`);
+	}
+
+	let ast;
+	try {
+		ast = parser.parse(code);
+	} catch (err) {
+		throw new Error('Aiscript syntax error');
+	}
+
+	const meta = Interpreter.collectMetadata(ast);
+	if (meta == null) {
+		throw new Error('Meta block not found');
+	}
+
+	const metadata = meta.get(null);
+	if (metadata == null) {
+		throw new Error('Metadata not found');
+	}
+
+	const { name, version, author, description, permissions, config } = metadata;
+	if (name == null || version == null || author == null) {
+		throw new Error('Required property not found');
+	}
+
+	return {
+		name,
+		version,
+		author,
+		description,
+		permissions,
+		config,
+	};
+}
+
+export async function authorizePlugin(plugin: Plugin) {
+	if (plugin.permissions == null || plugin.permissions.length === 0) return;
+	if (Object.hasOwn(store.state.pluginTokens, plugin.installId)) return;
+
+	const token = await new Promise<string>((res, rej) => {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
+			title: i18n.ts.tokenRequested,
+			information: i18n.ts.pluginTokenRequestedDescription,
+			initialName: plugin.name,
+			initialPermissions: plugin.permissions,
+		}, {
+			done: async result => {
+				const { name, permissions } = result;
+				const { token } = await misskeyApi('miauth/gen-token', {
+					session: null,
+					name: name,
+					permission: permissions,
+				});
+				res(token);
+			},
+			closed: () => dispose(),
+		});
+	});
+
+	store.set('pluginTokens', {
+		...store.state.pluginTokens,
+		[plugin.installId]: token,
+	});
+}
+
+export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
+	if (!code) return;
+
+	let realMeta: AiScriptPluginMeta;
+	if (!meta) {
+		realMeta = await parsePluginMeta(code);
+	} else {
+		realMeta = meta;
+	}
+
+	const installId = uuid();
+
+	const plugin = {
+		...realMeta,
+		installId,
+		active: true,
+		configData: {},
+		src: code,
+	};
+
+	prefer.set('plugins', prefer.s.plugins.concat(plugin));
+
+	await authorizePlugin(plugin);
+}
+
+export async function uninstallPlugin(plugin: Plugin) {
+	prefer.set('plugins', prefer.s.plugins.filter(x => x.installId !== plugin.installId));
+	if (Object.hasOwn(store.state.pluginTokens, plugin.installId)) {
+		await os.apiWithDialog('i/revoke-token', {
+			token: store.state.pluginTokens[plugin.installId],
+		});
+		const pluginTokens = { ...store.state.pluginTokens };
+		delete pluginTokens[plugin.installId];
+		store.set('pluginTokens', pluginTokens);
+	}
+}
+
+export async function configPlugin(plugin: Plugin) {
+	if (plugin.config == null) {
+		throw new Error('This plugin does not have a config');
+	}
+
+	const config = plugin.config;
+	for (const key in plugin.configData) {
+		config[key].default = plugin.configData[key];
+	}
+
+	const { canceled, result } = await os.form(plugin.name, config);
+	if (canceled) return;
+
+	prefer.set('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, configData: result } : x));
+}
+
+export function changePluginActive(plugin: Plugin, active: boolean) {
+	prefer.set('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, active } : x));
+}
+
 const pluginContexts = new Map<string, Interpreter>();
 export const pluginLogs = ref(new Map<string, string[]>());
 
-export async function install(plugin: Plugin): Promise<void> {
+export async function launchPlugin(plugin: Plugin): Promise<void> {
 	// 後方互換性のため
 	if (plugin.src == null) return;
 
+	await authorizePlugin(plugin);
+
 	const aiscript = new Interpreter(createPluginEnv({
 		plugin: plugin,
-		storageKey: 'plugins:' + plugin.id,
+		storageKey: 'plugins:' + plugin.installId,
 	}), {
 		in: aiScriptReadline,
 		out: (value): void => {
 			console.log(value);
-			pluginLogs.value.get(plugin.id).push(utils.reprValue(value));
+			pluginLogs.value.get(plugin.installId).push(utils.reprValue(value));
 		},
 		log: (): void => {
 		},
 		err: (err): void => {
-			pluginLogs.value.get(plugin.id).push(`${err}`);
+			pluginLogs.value.get(plugin.installId).push(`${err}`);
 			throw err; // install時のtry-catchに反応させる
 		},
 	});
 
-	initPlugin({ plugin, aiscript });
+	pluginContexts.set(plugin.installId, aiscript);
+	pluginLogs.value.set(plugin.installId, []);
 
 	aiscript.exec(parser.parse(plugin.src)).then(
 		() => {
@@ -49,47 +218,36 @@ export async function install(plugin: Plugin): Promise<void> {
 }
 
 function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
+	const id = opts.plugin.installId;
+
 	const config = new Map<string, values.Value>();
 	for (const [k, v] of Object.entries(opts.plugin.config ?? {})) {
 		config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
 	}
 
 	return {
-		...createAiScriptEnv({ ...opts, token: opts.plugin.token }),
-		//#region Deprecated
-		'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
-			utils.assertString(title);
-			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
-			utils.assertString(title);
-			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
-			utils.assertString(title);
-			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
-		}),
-		//#endregion
+		...createAiScriptEnv({ ...opts, token: store.state.pluginTokens[id] }),
+
 		'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
+			registerPostFormAction({ pluginId: id, title: title.value, handler });
 		}),
 		'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
+			registerUserAction({ pluginId: id, title: title.value, handler });
 		}),
 		'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
+			registerNoteAction({ pluginId: id, title: title.value, handler });
 		}),
 		'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler });
+			registerNoteViewInterruptor({ pluginId: id, handler });
 		}),
 		'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNotePostInterruptor({ pluginId: opts.plugin.id, handler });
+			registerNotePostInterruptor({ pluginId: id, handler });
 		}),
 		'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => {
-			registerPageViewInterruptor({ pluginId: opts.plugin.id, handler });
+			registerPageViewInterruptor({ pluginId: id, handler });
 		}),
 		'Plugin:open_url': values.FN_NATIVE(([url]) => {
 			utils.assertString(url);
@@ -99,11 +257,6 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
 	};
 }
 
-function initPlugin({ plugin, aiscript }): void {
-	pluginContexts.set(plugin.id, aiscript);
-	pluginLogs.value.set(plugin.id, []);
-}
-
 function registerPostFormAction({ pluginId, title, handler }): void {
 	postFormActions.push({
 		title, handler: (form, update) => {
diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts
new file mode 100644
index 0000000000..03da3be465
--- /dev/null
+++ b/packages/frontend/src/preferences.ts
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { v4 as uuid } from 'uuid';
+import type { PreferencesProfile } from '@/preferences/profile.js';
+import { cloudBackup } from '@/preferences/utility.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { ProfileManager } from '@/preferences/profile.js';
+import { store } from '@/store.js';
+import { $i } from '@/account.js';
+
+const TAB_ID = uuid();
+
+function createProfileManager() {
+	let profile: PreferencesProfile;
+
+	const savedProfileRaw = miLocalStorage.getItem('preferences');
+	if (savedProfileRaw == null) {
+		profile = ProfileManager.newProfile();
+		miLocalStorage.setItem('preferences', JSON.stringify(profile));
+	} else {
+		profile = ProfileManager.normalizeProfile(JSON.parse(savedProfileRaw));
+	}
+
+	return new ProfileManager(profile);
+}
+
+export const profileManager = createProfileManager();
+profileManager.addListener('updated', ({ profile: p }) => {
+	miLocalStorage.setItem('preferences', JSON.stringify(p));
+	miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`);
+});
+export const prefer = profileManager.store;
+
+let latestSyncedAt = Date.now();
+
+function syncBetweenTabs() {
+	const latest = miLocalStorage.getItem('latestPreferencesUpdate');
+	if (latest == null) return;
+
+	const latestTab = latest.split('/')[0];
+	const latestAt = parseInt(latest.split('/')[1]);
+
+	if (latestTab === TAB_ID) return;
+	if (latestAt <= latestSyncedAt) return;
+
+	profileManager.rewriteProfile(ProfileManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!)));
+
+	latestSyncedAt = Date.now();
+
+	if (_DEV_) console.log('prefer:synced');
+}
+
+window.setInterval(syncBetweenTabs, 5000);
+
+document.addEventListener('visibilitychange', () => {
+	if (document.visibilityState === 'visible') {
+		syncBetweenTabs();
+	}
+});
+
+let latestBackupAt = 0;
+
+window.setInterval(() => {
+	if ($i == null) return;
+	if (!store.state.enablePreferencesAutoCloudBackup) return;
+	if (document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
+	if (profileManager.profile.modifiedAt <= latestBackupAt) return;
+
+	cloudBackup().then(() => {
+		latestBackupAt = Date.now();
+	});
+}, 1000 * 60 * 3);
+
+if (_DEV_) {
+	(window as any).profileManager = profileManager;
+	(window as any).prefer = prefer;
+	(window as any).cloudBackup = cloudBackup;
+}
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
new file mode 100644
index 0000000000..19284de6a1
--- /dev/null
+++ b/packages/frontend/src/preferences/def.ts
@@ -0,0 +1,308 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { hemisphere } from '@@/js/intl-const.js';
+import type { Theme } from '@/scripts/theme.js';
+import type { SoundType } from '@/scripts/sound.js';
+import type { Plugin } from '@/plugin.js';
+import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
+
+/** サウンド設定 */
+export type SoundStore = {
+	type: Exclude<SoundType, '_driveFile_'>;
+	volume: number;
+} | {
+	type: '_driveFile_';
+
+	/** ドライブのファイルID */
+	fileId: string;
+
+	/** ファイルURL（こちらが優先される） */
+	fileUrl: string;
+
+	volume: number;
+};
+
+export const PREF_DEF = {
+	pinnedUserLists: {
+		accountDependent: true,
+		default: [] as Misskey.entities.UserList[],
+	},
+	uploadFolder: {
+		accountDependent: true,
+		default: null as string | null,
+	},
+
+	themes: {
+		default: [] as Theme[],
+	},
+	lightTheme: {
+		default: null as Theme | null,
+	},
+	darkTheme: {
+		default: null as Theme | null,
+	},
+	syncDeviceDarkMode: {
+		default: true,
+	},
+	defaultNoteVisibility: {
+		default: 'public' as (typeof Misskey.noteVisibilities)[number],
+	},
+	defaultNoteLocalOnly: {
+		default: false,
+	},
+	keepCw: {
+		default: true,
+	},
+	keepOriginalUploading: {
+		default: false,
+	},
+	rememberNoteVisibility: {
+		default: false,
+	},
+	reportError: {
+		default: false,
+	},
+	collapseRenotes: {
+		default: true,
+	},
+	menu: {
+		default: [
+			'notifications',
+			'clips',
+			'drive',
+			'followRequests',
+			'-',
+			'explore',
+			'announcements',
+			'search',
+			'-',
+			'ui',
+		],
+	},
+	statusbars: {
+		default: [] as {
+			name: string;
+			id: string;
+			type: string;
+			size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
+			black: boolean;
+			props: Record<string, any>;
+		}[],
+	},
+	serverDisconnectedBehavior: {
+		default: 'quiet' as 'quiet' | 'reload' | 'dialog',
+	},
+	nsfw: {
+		default: 'respect' as 'respect' | 'force' | 'ignore',
+	},
+	highlightSensitiveMedia: {
+		default: false,
+	},
+	animation: {
+		default: !window.matchMedia('(prefers-reduced-motion)').matches,
+	},
+	animatedMfm: {
+		default: !window.matchMedia('(prefers-reduced-motion)').matches,
+	},
+	advancedMfm: {
+		default: true,
+	},
+	showReactionsCount: {
+		default: false,
+	},
+	enableQuickAddMfmFunction: {
+		default: false,
+	},
+	loadRawImages: {
+		default: false,
+	},
+	imageNewTab: {
+		default: false,
+	},
+	disableShowingAnimatedImages: {
+		default: window.matchMedia('(prefers-reduced-motion)').matches,
+	},
+	emojiStyle: {
+		default: 'twemoji', // twemoji / fluentEmoji / native
+	},
+	menuStyle: {
+		default: 'auto' as 'auto' | 'popup' | 'drawer',
+	},
+	useBlurEffectForModal: {
+		default: DEFAULT_DEVICE_KIND === 'desktop',
+	},
+	useBlurEffect: {
+		default: DEFAULT_DEVICE_KIND === 'desktop',
+	},
+	showFixedPostForm: {
+		default: false,
+	},
+	showFixedPostFormInChannel: {
+		default: false,
+	},
+	enableInfiniteScroll: {
+		default: true,
+	},
+	useReactionPickerForContextMenu: {
+		default: false,
+	},
+	showGapBetweenNotesInTimeline: {
+		default: false,
+	},
+	instanceTicker: {
+		default: 'remote' as 'none' | 'remote' | 'always',
+	},
+	emojiPickerScale: {
+		default: 1,
+	},
+	emojiPickerWidth: {
+		default: 1,
+	},
+	emojiPickerHeight: {
+		default: 2,
+	},
+	emojiPickerStyle: {
+		default: 'auto' as 'auto' | 'popup' | 'drawer',
+	},
+	squareAvatars: {
+		default: false,
+	},
+	showAvatarDecorations: {
+		default: true,
+	},
+	numberOfPageCache: {
+		default: 3,
+	},
+	showNoteActionsOnlyHover: {
+		default: false,
+	},
+	showClipButtonInNoteFooter: {
+		default: false,
+	},
+	reactionsDisplaySize: {
+		default: 'medium' as 'small' | 'medium' | 'large',
+	},
+	limitWidthOfReaction: {
+		default: true,
+	},
+	forceShowAds: {
+		default: false,
+	},
+	aiChanMode: {
+		default: false,
+	},
+	devMode: {
+		default: false,
+	},
+	mediaListWithOneImageAppearance: {
+		default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3',
+	},
+	notificationPosition: {
+		default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom',
+	},
+	notificationStackAxis: {
+		default: 'horizontal' as 'vertical' | 'horizontal',
+	},
+	enableCondensedLine: {
+		default: true,
+	},
+	keepScreenOn: {
+		default: false,
+	},
+	disableStreamingTimeline: {
+		default: false,
+	},
+	useGroupedNotifications: {
+		default: true,
+	},
+	dataSaver: {
+		default: {
+			media: false,
+			avatar: false,
+			urlPreview: false,
+			code: false,
+		} as Record<string, boolean>,
+	},
+	hemisphere: {
+		default: hemisphere as 'N' | 'S',
+	},
+	enableSeasonalScreenEffect: {
+		default: false,
+	},
+	enableHorizontalSwipe: {
+		default: true,
+	},
+	useNativeUiForVideoAudioPlayer: {
+		default: false,
+	},
+	keepOriginalFilename: {
+		default: true,
+	},
+	alwaysConfirmFollow: {
+		default: true,
+	},
+	confirmWhenRevealingSensitiveMedia: {
+		default: false,
+	},
+	contextMenu: {
+		default: 'app' as 'app' | 'appWithShift' | 'native',
+	},
+	skipNoteRender: {
+		default: true,
+	},
+	showSoftWordMutedWord: {
+		default: false,
+	},
+	confirmOnReact: {
+		default: false,
+	},
+	plugins: {
+		default: [] as Plugin[],
+	},
+	'sound.masterVolume': {
+		default: 0.3,
+	},
+	'sound.notUseSound': {
+		default: false,
+	},
+	'sound.useSoundOnlyWhenActive': {
+		default: false,
+	},
+	'sound.on.note': {
+		default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore,
+	},
+	'sound.on.noteMy': {
+		default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore,
+	},
+	'sound.on.notification': {
+		default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore,
+	},
+	'sound.on.reaction': {
+		default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
+	},
+	'deck.alwaysShowMainColumn': {
+		default: true,
+	},
+	'deck.navWindow': {
+		default: true,
+	},
+	'deck.useSimpleUiForNonRootPages': {
+		default: true,
+	},
+	'deck.columnAlign': {
+		default: 'left' as 'left' | 'right' | 'center',
+	},
+	'game.dropAndFusion': {
+		default: {
+			bgmVolume: 0.25,
+			sfxVolume: 1,
+		},
+	},
+} satisfies Record<string, {
+	default: any;
+	accountDependent?: boolean;
+}>;
diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts
new file mode 100644
index 0000000000..516fa2b557
--- /dev/null
+++ b/packages/frontend/src/preferences/profile.ts
@@ -0,0 +1,236 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { host, version } from '@@/js/config.js';
+import { EventEmitter } from 'eventemitter3';
+import { PREF_DEF } from './def.js';
+import { Store } from './store.js';
+import type { MenuItem } from '@/types/menu.js';
+import { $i } from '@/account.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { i18n } from '@/i18n.js';
+
+// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
+
+//type DottedToNested<T extends Record<string, any>> = {
+//	[K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
+//};
+
+type PREF = typeof PREF_DEF;
+type ValueOf<K extends keyof PREF> = PREF[K]['default'];
+type Account = string; // <host>/<userId>
+
+type Cond = {
+	server: string | null; // 将来のため
+	account: Account | null;
+	device: string | null; // 将来のため
+};
+
+export type PreferencesProfile = {
+	id: string;
+	version: string;
+	type: 'main';
+	modifiedAt: number;
+	name: string;
+	preferences: {
+		[K in keyof PREF]: [Cond, ValueOf<K>][];
+	};
+	syncByAccount: [Account, keyof PREF][],
+};
+
+export class ProfileManager extends EventEmitter<{
+	updated: (ctx: {
+		profile: PreferencesProfile
+	}) => void;
+}> {
+	public profile: PreferencesProfile;
+	public store: Store<{
+		[K in keyof PREF]: ValueOf<K>;
+	}>;
+
+	constructor(profile: PreferencesProfile) {
+		super();
+		this.profile = profile;
+
+		const states = this.genStates();
+
+		this.store = new Store(states);
+		this.store.addListener('updated', ({ key, value }) => {
+			console.log('prefer:set', key, value);
+
+			const record = this.getMatchedRecord(key);
+			if (record[0].account == null && PREF_DEF[key].accountDependent) {
+				this.profile.preferences[key].push([{
+					server: null,
+					account: `${host}/${$i!.id}`,
+					device: null,
+				}, value]);
+				this.save();
+				return;
+			}
+
+			record[1] = value;
+			this.save();
+		});
+	}
+
+	private genStates() {
+		const states = {} as { [K in keyof PREF]: ValueOf<K> };
+		let key: keyof PREF;
+		for (key in PREF_DEF) {
+			const record = this.getMatchedRecord(key);
+			states[key] = record[1];
+		}
+
+		return states;
+	}
+
+	public static newProfile(): PreferencesProfile {
+		const data = {} as PreferencesProfile['preferences'];
+		let key: keyof PREF;
+		for (key in PREF_DEF) {
+			data[key] = [[{
+				server: null,
+				account: null,
+				device: null,
+			}, PREF_DEF[key].default]];
+		}
+		return {
+			id: uuid(),
+			version: version,
+			type: 'main',
+			modifiedAt: Date.now(),
+			name: '',
+			preferences: data,
+			syncByAccount: [],
+		};
+	}
+
+	public static normalizeProfile(profile: any): PreferencesProfile {
+		const data = {} as PreferencesProfile['preferences'];
+		let key: keyof PREF;
+		for (key in PREF_DEF) {
+			const records = profile.preferences[key];
+			if (records == null || records.length === 0) {
+				data[key] = [[{
+					server: null,
+					account: null,
+					device: null,
+				}, PREF_DEF[key].default]];
+				continue;
+			} else {
+				data[key] = records;
+			}
+		}
+
+		return {
+			...profile,
+			preferences: data,
+		};
+	}
+
+	public save() {
+		this.profile.modifiedAt = Date.now();
+		this.profile.version = version;
+		this.emit('updated', { profile: this.profile });
+	}
+
+	public getMatchedRecord<K extends keyof PREF>(key: K): [Cond, ValueOf<K>] {
+		const records = this.profile.preferences[key];
+
+		if ($i == null) return records.find(([cond, v]) => cond.account == null)!;
+
+		const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+		if (accountOverrideRecord) return accountOverrideRecord;
+
+		const record = records.find(([cond, v]) => cond.account == null);
+		return record!;
+	}
+
+	public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
+		if ($i == null) return false;
+		return this.profile.preferences[key].some(([cond, v]) => cond.account === `${host}/${$i!.id}`) ?? false;
+	}
+
+	public setAccountOverride<K extends keyof PREF>(key: K) {
+		if ($i == null) return;
+		if (PREF_DEF[key].accountDependent) throw new Error('already account-dependent');
+		if (this.isAccountOverrided(key)) return;
+
+		const records = this.profile.preferences[key];
+		records.push([{
+			server: null,
+			account: `${host}/${$i!.id}`,
+			device: null,
+		}, this.store.s[key]]);
+
+		this.save();
+	}
+
+	public clearAccountOverride<K extends keyof PREF>(key: K) {
+		if ($i == null) return;
+		if (PREF_DEF[key].accountDependent) throw new Error('cannot clear override for this account-dependent property');
+
+		const records = this.profile.preferences[key];
+
+		const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+		if (index === -1) return;
+
+		records.splice(index, 1);
+
+		this.store.rewrite(key, this.getMatchedRecord(key)[1]);
+
+		this.save();
+	}
+
+	public renameProfile(name: string) {
+		this.profile.name = name;
+		this.save();
+	}
+
+	public rewriteProfile(profile: PreferencesProfile) {
+		this.profile = profile;
+		const states = this.genStates();
+		for (const key in states) {
+			this.store.rewrite(key, states[key]);
+		}
+	}
+
+	public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
+		const overrideByAccount = ref(this.isAccountOverrided(key));
+
+		watch(overrideByAccount, () => {
+			if (overrideByAccount.value) {
+				this.setAccountOverride(key);
+			} else {
+				this.clearAccountOverride(key);
+			}
+		});
+
+		return [{
+			icon: 'ti ti-copy',
+			text: i18n.ts.copyPreferenceId,
+			action: () => {
+				copyToClipboard(key);
+			},
+		}, {
+			icon: 'ti ti-refresh',
+			text: i18n.ts.resetToDefaultValue,
+			danger: true,
+			action: () => {
+				this.store.set(key, PREF_DEF[key].default);
+			},
+		}, {
+			type: 'divider',
+		}, {
+			type: 'switch',
+			icon: 'ti ti-user-cog',
+			text: i18n.ts.overrideByAccount,
+			ref: overrideByAccount,
+		}];
+	}
+}
diff --git a/packages/frontend/src/preferences/store.ts b/packages/frontend/src/preferences/store.ts
new file mode 100644
index 0000000000..1029346d81
--- /dev/null
+++ b/packages/frontend/src/preferences/store.ts
@@ -0,0 +1,92 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, onUnmounted, ref, watch } from 'vue';
+import { EventEmitter } from 'eventemitter3';
+import type { Ref, WritableComputedRef } from 'vue';
+
+// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
+
+//type DottedToNested<T extends Record<string, any>> = {
+//	[K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
+//};
+
+type StoreEvent<Data extends Record<string, any>> = {
+	updated: <K extends keyof Data>(ctx: {
+		key: K;
+		value: Data[K];
+	}) => void;
+};
+
+export class Store<Data extends Record<string, any>> extends EventEmitter<StoreEvent<Data>> {
+	/**
+	 * static の略 (static が予約語のため)
+	 */
+	public s = {} as {
+		[K in keyof Data]: Data[K];
+	};
+
+	/**
+	 * reactive の略
+	 */
+	public r = {} as {
+		[K in keyof Data]: Ref<Data[K]>;
+	};
+
+	constructor(data: { [K in keyof Data]: Data[K] }) {
+		super();
+
+		for (const key in data) {
+			this.s[key] = data[key];
+			this.r[key] = ref(this.s[key]);
+		}
+	}
+
+	public set<K extends keyof Data>(key: K, value: Data[K]) {
+		this.r[key].value = this.s[key] = value;
+		this.emit('updated', { key, value });
+	}
+
+	public rewrite<K extends keyof Data>(key: K, value: Data[K]) {
+		this.r[key].value = this.s[key] = value;
+	}
+
+	/**
+	 * 特定のキーの、簡易的なcomputed refを作ります
+	 * 主にvue上で設定コントロールのmodelとして使う用
+	 */
+	public model<K extends keyof Data, V extends Data[K] = Data[K]>(
+		key: K,
+		getter?: (v: Data[K]) => V,
+		setter?: (v: V) => Data[K],
+	): WritableComputedRef<V> {
+		const valueRef = ref(this.s[key]);
+
+		const stop = watch(this.r[key], val => {
+			valueRef.value = val;
+		});
+
+		// NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
+		onUnmounted(() => {
+			stop();
+		});
+
+		// TODO: VueのcustomRef使うと良い感じになるかも
+		return computed({
+			get: () => {
+				if (getter) {
+					return getter(valueRef.value);
+				} else {
+					return valueRef.value;
+				}
+			},
+			set: (value) => {
+				const val = setter ? setter(value) : value;
+				this.set(key, val);
+				valueRef.value = val;
+			},
+		});
+	}
+}
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
new file mode 100644
index 0000000000..7979695231
--- /dev/null
+++ b/packages/frontend/src/preferences/utility.ts
@@ -0,0 +1,222 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, watch } from 'vue';
+import type { PreferencesProfile } from './profile.js';
+import type { MenuItem } from '@/types/menu.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { i18n } from '@/i18n.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { prefer, profileManager } from '@/preferences.js';
+import * as os from '@/os.js';
+import { store } from '@/store.js';
+import { $i } from '@/account.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { unisonReload } from '@/scripts/unison-reload.js';
+
+export function getPreferencesProfileMenu(): MenuItem[] {
+	const autoBackupEnabled = ref(store.state.enablePreferencesAutoCloudBackup);
+
+	watch(autoBackupEnabled, () => {
+		if (autoBackupEnabled.value) {
+			if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') {
+				autoBackupEnabled.value = false;
+				os.alert({
+					type: 'warning',
+					title: i18n.ts._preferencesBackup.youNeedToNameYourProfileToEnableAutoBackup,
+				});
+				return;
+			}
+
+			store.set('enablePreferencesAutoCloudBackup', true);
+		} else {
+			store.set('enablePreferencesAutoCloudBackup', false);
+		}
+	});
+
+	const menu: MenuItem[] = [{
+		type: 'label',
+		text: profileManager.profile.name || `(${i18n.ts.noName})`,
+	}, {
+		text: i18n.ts.rename,
+		icon: 'ti ti-pencil',
+		action: () => {
+			renameProfile();
+		},
+	}, {
+		type: 'switch',
+		icon: 'ti ti-cloud-up',
+		text: i18n.ts._preferencesBackup.autoBackup,
+		ref: autoBackupEnabled,
+	}, {
+		text: i18n.ts.export,
+		icon: 'ti ti-download',
+		action: () => {
+			exportCurrentProfile();
+		},
+	}, {
+		type: 'divider',
+	}, {
+		text: i18n.ts._preferencesBackup.restoreFromBackup,
+		icon: 'ti ti-cloud-down',
+		action: () => {
+			restoreFromCloudBackup();
+		},
+	}, {
+		text: i18n.ts.import,
+		icon: 'ti ti-upload',
+		action: () => {
+			importProfile();
+		},
+	}];
+
+	if (prefer.s.devMode) {
+		menu.push({
+			type: 'divider',
+		}, {
+			text: 'Copy profile as text',
+			icon: 'ti ti-clipboard',
+			action: () => {
+				copyToClipboard(JSON.stringify(profileManager.profile, null, '\t'));
+			},
+		});
+	}
+
+	return menu;
+}
+
+async function renameProfile() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts._preferencesProfile.profileName,
+		text: i18n.ts._preferencesProfile.profileNameDescription + '\n' + i18n.ts._preferencesProfile.profileNameDescription2,
+		placeholder: profileManager.profile.name || null,
+		default: profileManager.profile.name || null,
+	});
+	if (canceled || name == null || name.trim() === '') return;
+
+	profileManager.renameProfile(name);
+}
+
+function exportCurrentProfile() {
+	const p = profileManager.profile;
+	const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' });
+	const dummya = document.createElement('a');
+	dummya.href = URL.createObjectURL(txtBlob);
+	dummya.download = `${p.name || p.id}.misskeypreferences`;
+	dummya.click();
+}
+
+function importProfile() {
+	const input = document.createElement('input');
+	input.type = 'file';
+	input.accept = '.misskeypreferences';
+	input.onchange = async () => {
+		if (input.files == null || input.files.length === 0) return;
+
+		const file = input.files[0];
+		const txt = await file.text();
+		const profile = JSON.parse(txt) as PreferencesProfile;
+
+		miLocalStorage.setItem('preferences', JSON.stringify(profile));
+		miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true');
+		shouldSuggestRestoreBackup.value = false;
+		unisonReload();
+	};
+
+	input.click();
+}
+
+export async function cloudBackup() {
+	if ($i == null) return;
+	if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') {
+		throw new Error('Profile name is not set');
+	}
+
+	await misskeyApi('i/registry/set', {
+		scope: ['client', 'preferences', 'backups'],
+		key: profileManager.profile.name,
+		value: profileManager.profile,
+	});
+}
+
+export async function restoreFromCloudBackup() {
+	if ($i == null) return;
+
+	// TODO: 更新日時でソートして取得したい
+	const keys = await misskeyApi('i/registry/keys', {
+		scope: ['client', 'preferences', 'backups'],
+	});
+
+	console.log(keys);
+
+	if (keys.length === 0) {
+		os.alert({
+			type: 'warning',
+			title: i18n.ts._preferencesBackup.noBackupsFoundTitle,
+			text: i18n.ts._preferencesBackup.noBackupsFoundDescription,
+		});
+		return;
+	}
+
+	const select = await os.select({
+		title: i18n.ts._preferencesBackup.selectBackupToRestore,
+		items: keys.map(k => ({
+			text: k,
+			value: k,
+		})),
+	});
+	if (select.canceled) return;
+	if (select.result == null) return;
+
+	const profile = await misskeyApi('i/registry/get', {
+		scope: ['client', 'preferences', 'backups'],
+		key: select.result,
+	});
+
+	console.log(profile);
+
+	miLocalStorage.setItem('preferences', JSON.stringify(profile));
+	miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true');
+	store.set('enablePreferencesAutoCloudBackup', true);
+	shouldSuggestRestoreBackup.value = false;
+	unisonReload();
+}
+
+export async function enableAutoBackup() {
+	if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') {
+		await renameProfile();
+	}
+
+	if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') {
+		return;
+	}
+
+	store.set('enablePreferencesAutoCloudBackup', true);
+}
+
+export const shouldSuggestRestoreBackup = ref(false);
+
+if ($i != null) {
+	if (new Date($i.createdAt).getTime() < (Date.now() - 1000 * 60 * 30)) { // アカウント作成直後は意味ないので除外
+		miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true');
+	} else {
+		if (miLocalStorage.getItem('hidePreferencesRestoreSuggestion') !== 'true') {
+			misskeyApi('i/registry/keys', {
+				scope: ['client', 'preferences', 'backups'],
+			}).then(keys => {
+				if (keys.length === 0) {
+					miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true');
+				} else {
+					shouldSuggestRestoreBackup.value = true;
+				}
+			});
+		}
+	}
+}
+
+export function hideRestoreBackupSuggestion() {
+	miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true');
+	shouldSuggestRestoreBackup.value = false;
+}
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index c6ee128f5f..7e0efa2e89 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -165,10 +165,6 @@ const routes: RouteDef[] = [{
 		path: '/deck',
 		name: 'deck',
 		component: page(() => import('@/pages/settings/deck.vue')),
-	}, {
-		path: '/preferences-backups',
-		name: 'preferences-backups',
-		component: page(() => import('@/pages/settings/preferences-backups.vue')),
 	}, {
 		path: '/custom-css',
 		name: 'preferences',
diff --git a/packages/frontend/src/scripts/autogen/settings-search-index.ts b/packages/frontend/src/scripts/autogen/settings-search-index.ts
index c62272b271..983bc07d38 100644
--- a/packages/frontend/src/scripts/autogen/settings-search-index.ts
+++ b/packages/frontend/src/scripts/autogen/settings-search-index.ts
@@ -57,12 +57,12 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['mute'],
 			},
 			{
-				id: 'xy5OOBB4A',
+				id: 'oALW4ja7U',
 				label: i18n.ts.useSoundOnlyWhenActive,
 				keywords: ['active', 'mute'],
 			},
 			{
-				id: '9MxYVIf7k',
+				id: 'BbJK2SKT2',
 				label: i18n.ts.masterVolume,
 				keywords: ['volume', 'master'],
 			},
@@ -267,15 +267,10 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['remember', 'keep', 'note', 'visibility'],
 			},
 			{
-				id: 'rhKwScbVS',
+				id: '1u5HZuujV',
 				label: i18n.ts.defaultNoteVisibility,
 				keywords: ['default', 'note', 'visibility'],
 			},
-			{
-				id: '3EmXVyevo',
-				label: i18n.ts.keepCw,
-				keywords: ['remember', 'keep', 'note', 'cw'],
-			},
 		],
 		label: i18n.ts.privacy,
 		keywords: ['privacy'],
@@ -301,50 +296,50 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['post', 'form', 'timeline'],
 			},
 			{
-				id: '9ra14w32V',
+				id: 'snyCQ5oKE',
 				label: i18n.ts.showFixedPostFormInChannel,
 				keywords: ['post', 'form', 'timeline', 'channel'],
 			},
 			{
-				id: '84MdeDWL1',
+				id: '8j36S4Ev6',
 				label: i18n.ts.pinnedList,
 				keywords: ['pinned', 'list'],
 			},
 			{
-				id: 'fYdWhBbrN',
+				id: 'CWpyT9vLK',
 				label: i18n.ts.enableQuickAddMfmFunction,
 				keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'],
 			},
 			{
-				id: '4huRldNp5',
+				id: 'puIqj1a8b',
 				children: [
 					{
-						id: 'puIqj1a8b',
+						id: '1x3JNXj8N',
 						label: i18n.ts.collapseRenotes,
 						keywords: ['renote', i18n.ts.collapseRenotesDescription],
 					},
 					{
-						id: 'wqpOC22Zm',
+						id: 'c98gbF9c6',
 						label: i18n.ts.showNoteActionsOnlyHover,
 						keywords: ['hover', 'show', 'footer', 'action'],
 					},
 					{
-						id: 'cjfAtxMzP',
+						id: '4LxdiOMNh',
 						label: i18n.ts.showClipButtonInNoteFooter,
 						keywords: ['footer', 'action', 'clip', 'show'],
 					},
 					{
-						id: 'khzxoCjtp',
+						id: '9gTCaLkIf',
 						label: i18n.ts.enableAdvancedMfm,
 						keywords: ['mfm', 'enable', 'show', 'advanced'],
 					},
 					{
-						id: 'uJkoVjTmF',
+						id: '6kMj4HVOg',
 						label: i18n.ts.showReactionsCount,
 						keywords: ['reaction', 'count', 'show'],
 					},
 					{
-						id: '9gTCaLkIf',
+						id: 'dPersnkzh',
 						label: i18n.ts.loadRawImages,
 						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'],
 					},
@@ -353,10 +348,10 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['note'],
 			},
 			{
-				id: '5G6O6qdis',
+				id: '5XhS6ukl8',
 				children: [
 					{
-						id: 'sYTvqUbhP',
+						id: '3GcWIaZf8',
 						label: i18n.ts.useGroupedNotifications,
 						keywords: ['group'],
 					},
@@ -365,55 +360,60 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['notification'],
 			},
 			{
-				id: 'c3xhLyXZ5',
+				id: 'dSGDnj2PA',
 				children: [
 					{
-						id: 'FbhoeuRAD',
+						id: '1LHOhDKGW',
 						label: i18n.ts.openImageInNewTab,
 						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'],
 					},
 					{
-						id: 'qixh85g2N',
+						id: 'DSzwvTp7i',
 						label: i18n.ts.useReactionPickerForContextMenu,
 						keywords: ['reaction', 'picker', 'contextmenu', 'open'],
 					},
 					{
-						id: 'd2H4E5ys6',
+						id: '5QTUzrpT3',
 						label: i18n.ts.enableInfiniteScroll,
 						keywords: ['load', 'auto', 'more'],
 					},
 					{
-						id: 'jC7LtTnmc',
+						id: '7Uf8ksn3q',
 						label: i18n.ts.disableStreamingTimeline,
 						keywords: ['disable', 'streaming', 'timeline'],
 					},
 					{
-						id: '8xazEqlgZ',
+						id: 'whKYKvaQB',
 						label: i18n.ts.alwaysConfirmFollow,
 						keywords: ['follow', 'confirm', 'always'],
 					},
 					{
-						id: 'wZqrDQZar',
+						id: 'nf4kcPeYw',
 						label: i18n.ts.confirmWhenRevealingSensitiveMedia,
 						keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'],
 					},
 					{
-						id: '5QTUzrpT3',
+						id: 'rRisK1YYQ',
 						label: i18n.ts.confirmOnReact,
 						keywords: ['reaction', 'confirm'],
 					},
 					{
-						id: 'nygexkaUk',
+						id: '6AH0lnjf1',
+						label: i18n.ts.keepCw,
+						keywords: ['remember', 'keep', 'note', 'cw'],
+					},
+					{
+						id: 'uHcTVSGDv',
 						label: i18n.ts.whenServerDisconnected,
 						keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'],
 					},
 					{
-						id: 'whKYKvaQB',
+						id: 'fzPca1Gk9',
 						label: i18n.ts.numberOfPageCache,
 						keywords: ['cache', 'page'],
 					},
 					{
-						id: 'lBbtAg0Hm',
+						id: 'mNU5IBln7',
 						label: i18n.ts.dataSaver,
 						keywords: ['datasaver'],
 					},
@@ -422,20 +422,20 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['behavior'],
 			},
 			{
-				id: 'y2v7CV9zs',
+				id: 'C3psHYdZn',
 				children: [
 					{
-						id: 'k1qTdyfzM',
+						id: 'iCEiAg4Wg',
 						label: i18n.ts.forceShowAds,
 						keywords: ['ad', 'show'],
 					},
 					{
-						id: 'e9As4Us48',
+						id: 'qj9eChQ5B',
 						label: i18n.ts.hemisphere,
 						keywords: [],
 					},
 					{
-						id: 'zvM13vl26',
+						id: 'uItIge5hw',
 						label: i18n.ts.additionalEmojiDictionary,
 						keywords: ['emoji', 'dictionary', 'additional', 'extra'],
 					},
@@ -449,6 +449,13 @@ export const searchIndexes: SearchIndexItem[] = [
 		path: '/settings/preferences',
 		icon: 'ti ti-adjustments',
 	},
+	{
+		id: 'mwkwtw83Y',
+		label: i18n.ts.plugins,
+		keywords: ['plugin'],
+		path: '/settings/plugin',
+		icon: 'ti ti-plug',
+	},
 	{
 		id: 'F1uK9ssiY',
 		children: [
@@ -626,17 +633,17 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription],
 			},
 			{
-				id: 'oqUiI5w0s',
+				id: 'D8HUTGWE1',
 				label: i18n.ts.keepOriginalFilename,
 				keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription],
 			},
 			{
-				id: 'Aszkikq9n',
+				id: '6xAvsWSZi',
 				label: i18n.ts.alwaysMarkSensitive,
 				keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'],
 			},
 			{
-				id: 'iGlVjsfVj',
+				id: 'csNNPF1KX',
 				label: i18n.ts.enableAutoSensitive,
 				keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription],
 			},
@@ -662,80 +669,80 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['blur'],
 			},
 			{
-				id: 'vbZvyLDC1',
+				id: 'C05WQNSIJ',
 				label: i18n.ts.useBlurEffectForModal,
 				keywords: ['blur', 'modal'],
 			},
 			{
-				id: '6fLNMTwNt',
+				id: 'snVKNr7Bw',
 				label: i18n.ts.highlightSensitiveMedia,
 				keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'],
 			},
 			{
-				id: 'hhvF8Z4pF',
+				id: 'DsS2CwjYE',
 				label: i18n.ts.squareAvatars,
 				keywords: ['avatar', 'icon', 'square'],
 			},
 			{
-				id: 'DsS2CwjYE',
+				id: 'xCcTDl651',
 				label: i18n.ts.showAvatarDecorations,
 				keywords: ['avatar', 'icon', 'decoration', 'show'],
 			},
 			{
-				id: 'pWZ0ypy2g',
+				id: '3dHw723VD',
 				label: i18n.ts.showGapBetweenNotesInTimeline,
 				keywords: ['note', 'timeline', 'gap'],
 			},
 			{
-				id: 'AfRMcC6IM',
-				label: i18n.ts.useSystemFont,
-				keywords: ['font', 'system', 'native'],
-			},
-			{
-				id: 'jD0qbxlzN',
+				id: 'AWi72xbrl',
 				label: i18n.ts.seasonalScreenEffect,
 				keywords: ['effect', 'show'],
 			},
 			{
-				id: 'EdYo3hOK',
+				id: 'Ces8FsJws',
 				label: i18n.ts.menuStyle,
 				keywords: ['menu', 'style', 'popup', 'drawer'],
 			},
 			{
-				id: '9mSlX0EkD',
+				id: 'wDr9xSXCv',
 				label: i18n.ts.emojiStyle,
 				keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'],
 			},
 			{
-				id: '44UmMwmUe',
+				id: 'vFB0pLzck',
 				label: i18n.ts.fontSize,
 				keywords: ['font', 'size'],
 			},
 			{
-				id: 'vFB0pLzck',
+				id: '23BhvYXPC',
+				label: i18n.ts.useSystemFont,
+				keywords: ['font', 'system', 'native'],
+			},
+			{
+				id: 'EeNLndAOa',
 				children: [
 					{
-						id: 'pc7IpPEU4',
+						id: 'rAAPoaodS',
 						label: i18n.ts.reactionsDisplaySize,
 						keywords: ['reaction', 'size', 'scale', 'display'],
 					},
 					{
-						id: 'siOW5aSwp',
+						id: 'qTLAvNWsc',
 						label: i18n.ts.limitWidthOfReaction,
 						keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'],
 					},
 					{
-						id: 'dDUvhk13F',
+						id: '2lWgzAm13',
 						label: i18n.ts.mediaListWithOneImageAppearance,
 						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'],
 					},
 					{
-						id: 'CLxNL1Rp0',
+						id: 'EU7HbxOR5',
 						label: i18n.ts.instanceTicker,
 						keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'],
 					},
 					{
-						id: 'dP2KWDYzD',
+						id: 'AEtM0FAp1',
 						label: i18n.ts.displayOfSensitiveMedia,
 						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'],
 					},
@@ -744,15 +751,15 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['note', 'display'],
 			},
 			{
-				id: 'dVOzi22IW',
+				id: 'A1FMC2Zon',
 				children: [
 					{
-						id: 'aoF4ufUwn',
+						id: 'CB37G5ZDo',
 						label: i18n.ts.position,
 						keywords: ['position'],
 					},
 					{
-						id: 'sKK2XSS69',
+						id: 'gGS2i19hS',
 						label: i18n.ts.stackAxis,
 						keywords: ['stack', 'axis', 'direction'],
 					},
@@ -775,32 +782,32 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['animation', 'motion', 'reduce'],
 			},
 			{
-				id: 'RhYwm8At',
+				id: 'cXr3tFdpa',
 				label: i18n.ts.disableShowingAnimatedImages,
 				keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'],
 			},
 			{
-				id: '5mZxz2cru',
+				id: 'Ok1UBwtP',
 				label: i18n.ts.enableAnimatedMfm,
 				keywords: ['mfm', 'enable', 'show', 'animated'],
 			},
 			{
-				id: 'bgjamYEis',
+				id: 'yPEpJigqY',
 				label: i18n.ts.enableHorizontalSwipe,
 				keywords: ['swipe', 'horizontal', 'tab'],
 			},
 			{
-				id: 'yPEpJigqY',
+				id: 'h7iZtdTU3',
 				label: i18n.ts.keepScreenOn,
 				keywords: ['keep', 'screen', 'display', 'on'],
 			},
 			{
-				id: 'oxwiGKMu0',
+				id: 'gP1BY3PDy',
 				label: i18n.ts.useNativeUIForVideoAudioPlayer,
 				keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
 			},
 			{
-				id: 'n90tffyiU',
+				id: 'jnMK3M6rs',
 				label: i18n.ts._contextMenu.title,
 				keywords: ['contextmenu', 'system', 'native'],
 			},
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
index 4d57dcd944..4f2aff9d4c 100644
--- a/packages/frontend/src/scripts/code-highlighter.ts
+++ b/packages/frontend/src/scripts/code-highlighter.ts
@@ -10,18 +10,20 @@ import { bundledThemesInfo } from 'shiki/themes';
 import { bundledLanguagesInfo } from 'shiki/langs';
 import lightTheme from '@@/themes/_light.json5';
 import darkTheme from '@@/themes/_dark.json5';
+import defaultLightTheme from '@@/themes/l-light.json5';
+import defaultDarkTheme from '@@/themes/d-green-lime.json5';
 import { unique } from './array.js';
 import { deepClone } from './clone.js';
 import { deepMerge } from './merge.js';
 import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
-import { ColdDeviceStorage } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 let _highlighter: HighlighterCore | null = null;
 
 export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
 export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
 export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> {
-	const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme'));
+	const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme);
 
 	if (theme.base) {
 		const base = [lightTheme, darkTheme].find(x => x.id === theme.base);
@@ -77,19 +79,19 @@ async function initHighlighter() {
 		],
 	});
 
-	ColdDeviceStorage.watch('lightTheme', async () => {
-		const newTheme = await getTheme('light');
-		if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
-			highlighter.loadTheme(newTheme);
-		}
-	});
-
-	ColdDeviceStorage.watch('darkTheme', async () => {
-		const newTheme = await getTheme('dark');
-		if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
-			highlighter.loadTheme(newTheme);
-		}
-	});
+	// TODO
+	//watch('lightTheme', async () => {
+	//	const newTheme = await getTheme('light');
+	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
+	//		highlighter.loadTheme(newTheme);
+	//	}
+	//});
+	//watch('darkTheme', async () => {
+	//	const newTheme = await getTheme('dark');
+	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
+	//		highlighter.loadTheme(newTheme);
+	//	}
+	//});
 
 	_highlighter = highlighter;
 
diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/scripts/emoji-picker.ts
index e704b5fd6f..7ff5863243 100644
--- a/packages/frontend/src/scripts/emoji-picker.ts
+++ b/packages/frontend/src/scripts/emoji-picker.ts
@@ -6,7 +6,7 @@
 import { defineAsyncComponent, ref } from 'vue';
 import type { Ref } from 'vue';
 import { popup } from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 /**
  * 絵文字ピッカーを表示する。
@@ -25,7 +25,7 @@ class EmojiPicker {
 	}
 
 	public async init() {
-		const emojisRef = defaultStore.reactiveState.pinnedEmojis;
+		const emojisRef = store.reactiveState.pinnedEmojis;
 		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
 			src: this.src,
 			pinnedEmojis: emojisRef,
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index c8ab9238d3..6ac0684170 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -5,12 +5,12 @@
 
 import * as Misskey from 'misskey-js';
 import { defineAsyncComponent } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
 import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import type { MenuItem } from '@/types/menu.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 function rename(file: Misskey.entities.DriveFile) {
 	os.inputText({
@@ -148,7 +148,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
 		action: () => deleteFile(file),
 	});
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyFileId,
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 84122c0e60..8ce4a81bd4 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -15,7 +15,7 @@ import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { defaultStore, noteActions } from '@/store.js';
+import { store, noteActions } from '@/store.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { getUserMenu } from '@/scripts/get-user-menu.js';
 import { clipsCache, favoritedChannelsCache } from '@/cache.js';
@@ -23,6 +23,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { isSupportShare } from '@/scripts/navigator.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { genEmbedCode } from '@/scripts/get-embed-code.js';
+import { prefer } from '@/preferences.js';
 
 export async function getNoteClipMenu(props: {
 	note: Misskey.entities.Note;
@@ -507,7 +508,7 @@ export function getNoteMenu(props: {
 		})));
 	}
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyNoteId,
@@ -558,7 +559,7 @@ export function getRenoteMenu(props: {
 			icon: 'ti ti-repeat',
 			action: () => {
 				const el = props.renoteButton.value;
-				if (el && defaultStore.state.animation) {
+				if (el && prefer.s.animation) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
@@ -596,7 +597,7 @@ export function getRenoteMenu(props: {
 			icon: 'ti ti-repeat',
 			action: () => {
 				const el = props.renoteButton.value;
-				if (el && defaultStore.state.animation) {
+				if (el && prefer.s.animation) {
 					const rect = el.getBoundingClientRect();
 					const x = rect.left + (el.offsetWidth / 2);
 					const y = rect.top + (el.offsetHeight / 2);
@@ -605,8 +606,8 @@ export function getRenoteMenu(props: {
 					});
 				}
 
-				const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
-				const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
+				const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility;
+				const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly;
 
 				let visibility = appearNote.visibility;
 				visibility = smallerVisibility(visibility, configuredVisibility);
@@ -647,7 +648,7 @@ export function getRenoteMenu(props: {
 					text: channel.name,
 					action: () => {
 						const el = props.renoteButton.value;
-						if (el && defaultStore.state.animation) {
+						if (el && prefer.s.animation) {
 							const rect = el.getBoundingClientRect();
 							const x = rect.left + (el.offsetWidth / 2);
 							const y = rect.top + (el.offsetHeight / 2);
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 8f7c3ba3be..6892c3a4e4 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -6,19 +6,20 @@
 import { toUnicode } from 'punycode.js';
 import { defineAsyncComponent, ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import { host, url } from '@@/js/config.js';
+import type { IRouter } from '@/nirax.js';
+import type { MenuItem } from '@/types/menu.js';
 import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { host, url } from '@@/js/config.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore, userActions } from '@/store.js';
+import { userActions } from '@/store.js';
 import { $i, iAmModerator } from '@/account.js';
 import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
-import type { IRouter } from '@/nirax.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
 import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import type { MenuItem } from '@/types/menu.js';
+import { prefer } from '@/preferences.js';
 
 export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
 	const meId = $i ? $i.id : null;
@@ -251,7 +252,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 								listId: list.id,
 								userId: user.id,
 							}).then(() => {
-								list.userIds?.splice(list.userIds?.indexOf(user.id), 1);
+								list.userIds?.splice(list.userIds.indexOf(user.id), 1);
 							});
 						}
 					}));
@@ -398,7 +399,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 		});
 	}
 
-	if (defaultStore.state.devMode) {
+	if (prefer.s.devMode) {
 		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-id',
 			text: i18n.ts.copyUserId,
diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts
index 41e1636aa7..037b0d9567 100644
--- a/packages/frontend/src/scripts/init-chart.ts
+++ b/packages/frontend/src/scripts/init-chart.ts
@@ -24,7 +24,7 @@ import {
 import gradient from 'chartjs-plugin-gradient';
 import zoomPlugin from 'chartjs-plugin-zoom';
 import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import 'chartjs-adapter-date-fns';
 
 export function initChart() {
@@ -52,7 +52,7 @@ export function initChart() {
 	// フォントカラー
 	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
 
-	Chart.defaults.borderColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+	Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
 
 	Chart.defaults.animation = false;
 }
diff --git a/packages/frontend/src/scripts/install-plugin.ts b/packages/frontend/src/scripts/install-plugin.ts
deleted file mode 100644
index 37f473b6de..0000000000
--- a/packages/frontend/src/scripts/install-plugin.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent } from 'vue';
-import { compareVersions } from 'compare-versions';
-import { v4 as uuid } from 'uuid';
-import { Interpreter, Parser, utils } from '@syuilo/aiscript';
-import type { Plugin } from '@/store.js';
-import { ColdDeviceStorage } from '@/store.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { i18n } from '@/i18n.js';
-
-export type AiScriptPluginMeta = {
-	name: string;
-	version: string;
-	author: string;
-	description?: string;
-	permissions?: string[];
-	config?: Record<string, any>;
-};
-
-const parser = new Parser();
-
-export function savePlugin({ id, meta, src, token }: {
-	id: string;
-	meta: AiScriptPluginMeta;
-	src: string;
-	token: string;
-}) {
-	ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
-		...meta,
-		id,
-		active: true,
-		configData: {},
-		token: token,
-		src: src,
-	} as Plugin));
-}
-
-export function isSupportedAiScriptVersion(version: string): boolean {
-	try {
-		return (compareVersions(version, '0.12.0') >= 0);
-	} catch (err) {
-		return false;
-	}
-}
-
-export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> {
-	if (!code) {
-		throw new Error('code is required');
-	}
-
-	const lv = utils.getLangVersion(code);
-	if (lv == null) {
-		throw new Error('No language version annotation found');
-	} else if (!isSupportedAiScriptVersion(lv)) {
-		throw new Error(`Aiscript version '${lv}' is not supported`);
-	}
-
-	let ast;
-	try {
-		ast = parser.parse(code);
-	} catch (err) {
-		throw new Error('Aiscript syntax error');
-	}
-
-	const meta = Interpreter.collectMetadata(ast);
-	if (meta == null) {
-		throw new Error('Meta block not found');
-	}
-
-	const metadata = meta.get(null);
-	if (metadata == null) {
-		throw new Error('Metadata not found');
-	}
-
-	const { name, version, author, description, permissions, config } = metadata;
-	if (name == null || version == null || author == null) {
-		throw new Error('Required property not found');
-	}
-
-	return {
-		name,
-		version,
-		author,
-		description,
-		permissions,
-		config,
-	};
-}
-
-export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
-	if (!code) return;
-
-	let realMeta: AiScriptPluginMeta;
-	if (!meta) {
-		realMeta = await parsePluginMeta(code);
-	} else {
-		realMeta = meta;
-	}
-
-	const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => {
-		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
-			title: i18n.ts.tokenRequested,
-			information: i18n.ts.pluginTokenRequestedDescription,
-			initialName: realMeta.name,
-			initialPermissions: realMeta.permissions,
-		}, {
-			done: async result => {
-				const { name, permissions } = result;
-				const { token } = await misskeyApi('miauth/gen-token', {
-					session: null,
-					name: name,
-					permission: permissions,
-				});
-				res(token);
-			},
-			closed: () => dispose(),
-		});
-	});
-
-	savePlugin({
-		id: uuid(),
-		meta: realMeta,
-		token,
-		src: code,
-	});
-}
diff --git a/packages/frontend/src/scripts/install-theme.ts b/packages/frontend/src/scripts/install-theme.ts
deleted file mode 100644
index cc32adcc6a..0000000000
--- a/packages/frontend/src/scripts/install-theme.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import JSON5 from 'json5';
-import { addTheme, getThemes } from '@/theme-store.js';
-import { applyTheme, validateTheme } from '@/scripts/theme.js';
-import type { Theme } from '@/scripts/theme.js';
-
-export function parseThemeCode(code: string): Theme {
-	let theme;
-
-	try {
-		theme = JSON5.parse(code);
-	} catch (err) {
-		throw new Error('Failed to parse theme json');
-	}
-	if (!validateTheme(theme)) {
-		throw new Error('This theme is invaild');
-	}
-	if (getThemes().some(t => t.id === theme.id)) {
-		throw new Error('This theme is already installed');
-	}
-
-	return theme;
-}
-
-export function previewTheme(code: string): void {
-	const theme = parseThemeCode(code);
-	if (theme) applyTheme(theme, false);
-}
-
-export async function installTheme(code: string): Promise<void> {
-	const theme = parseThemeCode(code);
-	if (!theme) return;
-	await addTheme(theme);
-}
diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts
index c142b3ed2a..81f6c02dcf 100644
--- a/packages/frontend/src/scripts/reaction-picker.ts
+++ b/packages/frontend/src/scripts/reaction-picker.ts
@@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js';
 import { defineAsyncComponent, ref } from 'vue';
 import type { Ref } from 'vue';
 import { popup } from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 class ReactionPicker {
 	private src: Ref<HTMLElement | null> = ref(null);
@@ -21,7 +21,7 @@ class ReactionPicker {
 	}
 
 	public async init() {
-		const reactionsRef = defaultStore.reactiveState.reactions;
+		const reactionsRef = store.reactiveState.reactions;
 		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
 			src: this.src,
 			pinnedEmojis: reactionsRef,
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
index c25b4d73bd..42b34f54f5 100644
--- a/packages/frontend/src/scripts/select-file.ts
+++ b/packages/frontend/src/scripts/select-file.ts
@@ -9,8 +9,8 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
 import { uploadFile } from '@/scripts/upload.js';
+import { prefer } from '@/preferences.js';
 
 export function chooseFileFromPc(
 	multiple: boolean,
@@ -20,8 +20,8 @@ export function chooseFileFromPc(
 		nameConverter?: (file: File) => string | undefined;
 	},
 ): Promise<Misskey.entities.DriveFile[]> {
-	const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder;
-	const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading;
+	const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
+	const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading;
 	const nameConverter = options?.nameConverter ?? (() => undefined);
 
 	return new Promise((res, rej) => {
@@ -82,7 +82,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
 
 			misskeyApi('drive/files/upload-from-url', {
 				url: url,
-				folderId: defaultStore.state.uploadFolder,
+				folderId: prefer.s.uploadFolder,
 				marker,
 			});
 
@@ -96,7 +96,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
 
 function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
 	return new Promise((res, rej) => {
-		const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
+		const keepOriginal = ref(prefer.s.keepOriginalUploading);
 
 		os.popupMenu([label ? {
 			text: label,
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 2008afe045..436c2b75f0 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -3,8 +3,9 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import type { SoundStore } from '@/store.js';
-import { defaultStore } from '@/store.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { prefer } from '@/preferences.js';
+import { PREF_DEF } from '@/preferences/def.js';
 
 let ctx: AudioContext;
 const cache = new Map<string, AudioBuffer>();
@@ -127,11 +128,11 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
  * @param type スプライトの種類を指定
  */
 export function playMisskeySfx(operationType: OperationType) {
-	const sound = defaultStore.state[`sound_${operationType}`];
+	const sound = prefer.s[`sound.on.${operationType}`];
 	playMisskeySfxFile(sound).then((succeed) => {
 		if (!succeed && sound.type === '_driveFile_') {
 			// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
-			const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
+			const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
 			if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
 			playMisskeySfxFileInternal({
 				type: soundName,
@@ -166,7 +167,7 @@ async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boole
 	if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
 		return false;
 	}
-	const masterVolume = defaultStore.state.sound_masterVolume;
+	const masterVolume = prefer.s['sound.masterVolume'];
 	if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
 		return true; // ミュート時は成功として扱う
 	}
@@ -198,10 +199,10 @@ export function createSourceNode(buffer: AudioBuffer, opts: {
 	pan?: number;
 	playbackRate?: number;
 }): {
-	soundSource: AudioBufferSourceNode;
-	panNode: StereoPannerNode;
-	gainNode: GainNode;
-} {
+		soundSource: AudioBufferSourceNode;
+		panNode: StereoPannerNode;
+		gainNode: GainNode;
+	} {
 	const panNode = ctx.createStereoPanner();
 	panNode.pan.value = opts.pan ?? 0;
 
@@ -242,13 +243,13 @@ export async function getSoundDuration(file: string): Promise<number> {
  * ミュートすべきかどうかを判断する
  */
 export function isMute(): boolean {
-	if (defaultStore.state.sound_notUseSound) {
+	if (prefer.s['sound.notUseSound']) {
 		// サウンドを出力しない
 		return true;
 	}
 
 	// noinspection RedundantIfStatementJS
-	if (defaultStore.state.sound_useSoundOnlyWhenActive && document.visibilityState === 'hidden') {
+	if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
 		// ブラウザがアクティブな時のみサウンドを出力する
 		return true;
 	}
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index 1a3909c132..851ba41e61 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -7,10 +7,12 @@ import { ref } from 'vue';
 import tinycolor from 'tinycolor2';
 import lightTheme from '@@/themes/_light.json5';
 import darkTheme from '@@/themes/_dark.json5';
+import JSON5 from 'json5';
 import { deepClone } from './clone.js';
 import type { BundledTheme } from 'shiki/themes';
 import { globalEvents } from '@/events.js';
 import { miLocalStorage } from '@/local-storage.js';
+import { addTheme, getThemes } from '@/theme-store.js';
 
 export type Theme = {
 	id: string;
@@ -101,6 +103,7 @@ export function applyTheme(theme: Theme, persist = true) {
 
 	if (persist) {
 		miLocalStorage.setItem('theme', JSON.stringify(props));
+		miLocalStorage.setItem('themeId', theme.id);
 		miLocalStorage.setItem('colorScheme', colorScheme);
 	}
 
@@ -155,3 +158,32 @@ export function validateTheme(theme: Record<string, any>): boolean {
 	if (theme.props == null || typeof theme.props !== 'object') return false;
 	return true;
 }
+
+export function parseThemeCode(code: string): Theme {
+	let theme;
+
+	try {
+		theme = JSON5.parse(code);
+	} catch (err) {
+		throw new Error('Failed to parse theme json');
+	}
+	if (!validateTheme(theme)) {
+		throw new Error('This theme is invaild');
+	}
+	if (getThemes().some(t => t.id === theme.id)) {
+		throw new Error('This theme is already installed');
+	}
+
+	return theme;
+}
+
+export function previewTheme(code: string): void {
+	const theme = parseThemeCode(code);
+	if (theme) applyTheme(theme, false);
+}
+
+export async function installTheme(code: string): Promise<void> {
+	const theme = parseThemeCode(code);
+	if (!theme) return;
+	await addTheme(theme);
+}
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
index 713573a377..d105a318a7 100644
--- a/packages/frontend/src/scripts/upload.ts
+++ b/packages/frontend/src/scripts/upload.ts
@@ -7,13 +7,13 @@ import { reactive, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { v4 as uuid } from 'uuid';
 import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
-import { getCompressionConfig } from './upload/compress-config.js';
-import { defaultStore } from '@/store.js';
 import { apiUrl } from '@@/js/config.js';
+import { getCompressionConfig } from './upload/compress-config.js';
 import { $i } from '@/account.js';
 import { alert } from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
+import { prefer } from '@/preferences.js';
 
 type Uploading = {
 	id: string;
@@ -34,7 +34,7 @@ export function uploadFile(
 	file: File,
 	folder?: string | Misskey.entities.DriveFolder,
 	name?: string,
-	keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
+	keepOriginal: boolean = prefer.s.keepOriginalUploading,
 ): Promise<Misskey.entities.DriveFile> {
 	if ($i == null) throw new Error('Not logged in');
 
@@ -59,7 +59,7 @@ export function uploadFile(
 
 			const ctx = reactive<Uploading>({
 				id,
-				name: defaultStore.state.keepOriginalFilename ? filename : id + extension,
+				name: prefer.s.keepOriginalFilename ? filename : id + extension,
 				progressMax: undefined,
 				progressValue: undefined,
 				img: window.URL.createObjectURL(file),
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 128fce315f..6e4b4cd0c1 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -5,15 +5,15 @@
 
 import { markRaw, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { hemisphere } from '@@/js/intl-const.js';
 import lightTheme from '@@/themes/l-light.json5';
 import darkTheme from '@@/themes/d-green-lime.json5';
-import type { SoundType } from '@/scripts/sound.js';
-import type { Ast } from '@syuilo/aiscript';
+import { hemisphere } from '@@/js/intl-const.js';
 import type { DeviceKind } from '@/scripts/device-kind.js';
-import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
+import type { Plugin } from '@/plugin.js';
+import type { Column } from '@/deck.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { Storage } from '@/pizzax.js';
+import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
 
 interface PostFormAction {
 	title: string,
@@ -42,22 +42,6 @@ interface PageViewInterruptor {
 	handler: (page: Misskey.entities.Page) => unknown;
 }
 
-/** サウンド設定 */
-export type SoundStore = {
-	type: Exclude<SoundType, '_driveFile_'>;
-	volume: number;
-} | {
-	type: '_driveFile_';
-
-	/** ドライブのファイルID */
-	fileId: string;
-
-	/** ファイルURL（こちらが優先される） */
-	fileUrl: string;
-
-	volume: number;
-};
-
 export const postFormActions: PostFormAction[] = [];
 export const userActions: UserAction[] = [];
 export const noteActions: NoteAction[] = [];
@@ -65,9 +49,10 @@ export const noteViewInterruptors: NoteViewInterruptor[] = [];
 export const notePostInterruptors: NotePostInterruptor[] = [];
 export const pageViewInterruptors: PageViewInterruptor[] = [];
 
-// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
-//       あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
-export const defaultStore = markRaw(new Storage('base', {
+/**
+ * 「状態」を管理するストア(not「設定」)
+ */
+export const store = markRaw(new Storage('base', {
 	accountSetupWizard: {
 		where: 'account',
 		default: 0,
@@ -85,38 +70,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'account',
 		default: false,
 	},
-	keepCw: {
-		where: 'account',
-		default: true,
-	},
-	collapseRenotes: {
-		where: 'account',
-		default: true,
-	},
-	rememberNoteVisibility: {
-		where: 'account',
-		default: false,
-	},
-	defaultNoteVisibility: {
-		where: 'account',
-		default: 'public' as (typeof Misskey.noteVisibilities)[number],
-	},
-	defaultNoteLocalOnly: {
-		where: 'account',
-		default: false,
-	},
-	uploadFolder: {
-		where: 'account',
-		default: null as string | null,
-	},
-	pastedFileName: {
-		where: 'account',
-		default: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
-	},
-	keepOriginalUploading: {
-		where: 'account',
-		default: false,
-	},
 	memo: {
 		where: 'account',
 		default: null,
@@ -137,22 +90,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'account',
 		default: [] as string[],
 	},
-
-	menu: {
-		where: 'deviceAccount',
-		default: [
-			'notifications',
-			'clips',
-			'drive',
-			'followRequests',
-			'-',
-			'explore',
-			'announcements',
-			'search',
-			'-',
-			'ui',
-		],
-	},
 	visibility: {
 		where: 'deviceAccount',
 		default: 'public' as (typeof Misskey.noteVisibilities)[number],
@@ -165,17 +102,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
-	statusbars: {
-		where: 'deviceAccount',
-		default: [] as {
-			name: string;
-			id: string;
-			type: string;
-			size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
-			black: boolean;
-			props: Record<string, any>;
-		}[],
-	},
 	widgets: {
 		where: 'account',
 		default: [] as {
@@ -198,14 +124,119 @@ export const defaultStore = markRaw(new Storage('base', {
 			},
 		},
 	},
-	pinnedUserLists: {
+	overridedDeviceKind: {
+		where: 'device',
+		default: null as DeviceKind | null,
+	},
+	darkMode: {
+		where: 'device',
+		default: false,
+	},
+	recentlyUsedEmojis: {
+		where: 'device',
+		default: [] as string[],
+	},
+	recentlyUsedUsers: {
+		where: 'device',
+		default: [] as string[],
+	},
+	menuDisplay: {
+		where: 'device',
+		default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top',
+	},
+	postFormWithHashtags: {
+		where: 'device',
+		default: false,
+	},
+	postFormHashtags: {
+		where: 'device',
+		default: '',
+	},
+	additionalUnicodeEmojiIndexes: {
+		where: 'device',
+		default: {} as Record<string, Record<string, string[]>>,
+	},
+	defaultWithReplies: {
+		where: 'account',
+		default: false,
+	},
+	pluginTokens: {
 		where: 'deviceAccount',
-		default: [] as Misskey.entities.UserList[],
+		default: {} as Record<string, string>, // plugin id, token
+	},
+	'deck.profile': {
+		where: 'deviceAccount',
+		default: 'default',
+	},
+	'deck.columns': {
+		where: 'deviceAccount',
+		default: [] as Column[],
+	},
+	'deck.layout': {
+		where: 'deviceAccount',
+		default: [] as Column['id'][][],
 	},
 
-	overridedDeviceKind: {
+	enablePreferencesAutoCloudBackup: {
 		where: 'device',
-		default: null as DeviceKind | null,
+		default: false,
+	},
+	showPreferencesAutoCloudBackupSuggestion: {
+		where: 'device',
+		default: true,
+	},
+
+	//#region TODO: そのうち消す (preferに移行済み)
+	defaultSideView: {
+		where: 'device',
+		default: false,
+	},
+	defaultNoteVisibility: {
+		where: 'account',
+		default: 'public' as (typeof Misskey.noteVisibilities)[number],
+	},
+	defaultNoteLocalOnly: {
+		where: 'account',
+		default: false,
+	},
+	keepCw: {
+		where: 'account',
+		default: true,
+	},
+	collapseRenotes: {
+		where: 'account',
+		default: true,
+	},
+	rememberNoteVisibility: {
+		where: 'account',
+		default: false,
+	},
+	uploadFolder: {
+		where: 'account',
+		default: null as string | null,
+	},
+	keepOriginalUploading: {
+		where: 'account',
+		default: false,
+	},
+	menu: {
+		where: 'deviceAccount',
+		default: [],
+	},
+	statusbars: {
+		where: 'deviceAccount',
+		default: [] as {
+			name: string;
+			id: string;
+			type: string;
+			size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge';
+			black: boolean;
+			props: Record<string, any>;
+		}[],
+	},
+	pinnedUserLists: {
+		where: 'deviceAccount',
+		default: [] as Misskey.entities.UserList[],
 	},
 	serverDisconnectedBehavior: {
 		where: 'device',
@@ -287,10 +318,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
-	darkMode: {
-		where: 'device',
-		default: false,
-	},
 	instanceTicker: {
 		where: 'device',
 		default: 'remote' as 'none' | 'remote' | 'always',
@@ -311,22 +338,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: 'auto' as 'auto' | 'popup' | 'drawer',
 	},
-	recentlyUsedEmojis: {
-		where: 'device',
-		default: [] as string[],
-	},
-	recentlyUsedUsers: {
-		where: 'device',
-		default: [] as string[],
-	},
-	defaultSideView: {
-		where: 'device',
-		default: false,
-	},
-	menuDisplay: {
-		where: 'device',
-		default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top',
-	},
 	reportError: {
 		where: 'device',
 		default: false,
@@ -339,18 +350,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: true,
 	},
-	postFormWithHashtags: {
-		where: 'device',
-		default: false,
-	},
-	postFormHashtags: {
-		where: 'device',
-		default: '',
-	},
-	themeInitial: {
-		where: 'device',
-		default: true,
-	},
 	numberOfPageCache: {
 		where: 'device',
 		default: 3,
@@ -399,18 +398,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: true,
 	},
-	additionalUnicodeEmojiIndexes: {
-		where: 'device',
-		default: {} as Record<string, Record<string, string[]>>,
-	},
 	keepScreenOn: {
 		where: 'device',
 		default: false,
 	},
-	defaultWithReplies: {
-		where: 'account',
-		default: false,
-	},
 	disableStreamingTimeline: {
 		where: 'device',
 		default: false,
@@ -432,17 +423,6 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
-	dropAndFusion: {
-		where: 'device',
-		default: {
-			bgmVolume: 0.25,
-			sfxVolume: 1,
-		},
-	},
-	hemisphere: {
-		where: 'device',
-		default: hemisphere as 'N' | 'S',
-	},
 	enableHorizontalSwipe: {
 		where: 'device',
 		default: true,
@@ -479,7 +459,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
-
+	hemisphere: {
+		where: 'device',
+		default: hemisphere as 'N' | 'S',
+	},
 	sound_masterVolume: {
 		where: 'device',
 		default: 0.3,
@@ -494,56 +477,49 @@ export const defaultStore = markRaw(new Storage('base', {
 	},
 	sound_note: {
 		where: 'device',
-		default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore,
+		default: { type: 'syuilo/n-aec', volume: 1 },
 	},
 	sound_noteMy: {
 		where: 'device',
-		default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore,
+		default: { type: 'syuilo/n-cea-4va', volume: 1 },
 	},
 	sound_notification: {
 		where: 'device',
-		default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore,
+		default: { type: 'syuilo/n-ea', volume: 1 },
 	},
 	sound_reaction: {
 		where: 'device',
-		default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
+		default: { type: 'syuilo/bubble2', volume: 1 },
 	},
+	dropAndFusion: {
+		where: 'device',
+		default: {
+			bgmVolume: 0.25,
+			sfxVolume: 1,
+		},
+	},
+	//#endregion
 }));
 
 // TODO: 他のタブと永続化されたstateを同期
 
 const PREFIX = 'miux:' as const;
 
-export type Plugin = {
-	id: string;
-	name: string;
-	active: boolean;
-	config?: Record<string, { default: any }>;
-	configData: Record<string, any>;
-	token: string;
-	src: string | null;
-	version: string;
-	ast: Ast.Node[];
-	author?: string;
-	description?: string;
-	permissions?: string[];
-};
-
 interface Watcher {
 	key: string;
 	callback: (value: unknown) => void;
 }
 
+// TODO: 消す(preferに移行済みのため)
 /**
  * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
  */
-
 export class ColdDeviceStorage {
 	public static default = {
-		lightTheme,
-		darkTheme,
-		syncDeviceDarkMode: true,
-		plugins: [] as Plugin[],
+		lightTheme, // TODO: 消す(preferに移行済みのため)
+		darkTheme, // TODO: 消す(preferに移行済みのため)
+		syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため)
+		plugins: [] as Plugin[], // TODO: 消す(preferに移行済みのため)
 	};
 
 	public static watchers: Watcher[] = [];
diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts
index fb010ae426..09c665a2ab 100644
--- a/packages/frontend/src/theme-store.ts
+++ b/packages/frontend/src/theme-store.ts
@@ -3,29 +3,14 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { getBuiltinThemes } from '@/scripts/theme.js';
 import type { Theme } from '@/scripts/theme.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { getBuiltinThemes } from '@/scripts/theme.js';
 import { $i } from '@/account.js';
-
-const lsCacheKey = $i ? `themes:${$i.id}` as const : null;
+import { prefer } from '@/preferences.js';
 
 export function getThemes(): Theme[] {
 	if ($i == null) return [];
-	return JSON.parse(miLocalStorage.getItem(lsCacheKey!) ?? '[]');
-}
-
-export async function fetchThemes(): Promise<void> {
-	if ($i == null) return;
-
-	try {
-		const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' });
-		miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes));
-	} catch (err) {
-		if (err.code === 'NO_SUCH_KEY') return;
-		throw err;
-	}
+	return prefer.s.themes;
 }
 
 export async function addTheme(theme: Theme): Promise<void> {
@@ -34,15 +19,15 @@ export async function addTheme(theme: Theme): Promise<void> {
 	if (builtinThemes.some(t => t.id === theme.id)) {
 		throw new Error('builtin theme');
 	}
-	await fetchThemes();
-	const themes = getThemes().concat(theme);
-	await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
-	miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes));
+	const themes = getThemes();
+	if (themes.some(t => t.id === theme.id)) {
+		throw new Error('already exists');
+	}
+	prefer.set('themes', [...themes, theme]);
 }
 
 export async function removeTheme(theme: Theme): Promise<void> {
 	if ($i == null) return;
 	const themes = getThemes().filter(t => t.id !== theme.id);
-	await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes });
-	miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes));
+	prefer.set('themes', themes);
 }
diff --git a/packages/frontend/src/ui/_common_/PreferenceRestore.vue b/packages/frontend/src/ui/_common_/PreferenceRestore.vue
new file mode 100644
index 0000000000..0412733350
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/PreferenceRestore.vue
@@ -0,0 +1,73 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+	<span :class="$style.icon">
+		<i class="ti ti-info-circle"></i>
+	</span>
+	<span :class="$style.title">{{ i18n.ts._preferencesBackup.backupFound }}</span>
+	<span :class="$style.body"><button class="_textButton" @click="restore">{{ i18n.ts.restore }}</button> | <button class="_textButton" @click="skip">{{ i18n.ts.skip }}</button></span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { hideRestoreBackupSuggestion, restoreFromCloudBackup } from '@/preferences/utility.js';
+
+function restore() {
+	restoreFromCloudBackup();
+}
+
+function skip() {
+	hideRestoreBackupSuggestion();
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	--height: 24px;
+	font-size: 0.85em;
+	display: flex;
+	vertical-align: bottom;
+	width: 100%;
+	line-height: var(--height);
+	height: var(--height);
+	overflow: clip;
+	contain: strict;
+	background: var(--MI_THEME-panel);
+
+	@container (max-width: 1000px) {
+		display: block;
+		text-align: center;
+
+		> .body {
+			display: none;
+		}
+	}
+}
+
+.icon {
+	margin-left: 10px;
+}
+
+.title {
+	padding: 0 10px;
+	font-weight: bold;
+
+	&:empty {
+		display: none;
+	}
+}
+
+.body {
+	min-width: 0;
+	flex: 1;
+	overflow: clip;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+}
+</style>
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index d145b9b6c6..51645f9676 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -17,18 +17,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 <TransitionGroup
 	tag="div"
 	:class="[$style.notifications, {
-		[$style.notificationsPosition_leftTop]: defaultStore.state.notificationPosition === 'leftTop',
-		[$style.notificationsPosition_leftBottom]: defaultStore.state.notificationPosition === 'leftBottom',
-		[$style.notificationsPosition_rightTop]: defaultStore.state.notificationPosition === 'rightTop',
-		[$style.notificationsPosition_rightBottom]: defaultStore.state.notificationPosition === 'rightBottom',
-		[$style.notificationsStackAxis_vertical]: defaultStore.state.notificationStackAxis === 'vertical',
-		[$style.notificationsStackAxis_horizontal]: defaultStore.state.notificationStackAxis === 'horizontal',
+		[$style.notificationsPosition_leftTop]: prefer.s.notificationPosition === 'leftTop',
+		[$style.notificationsPosition_leftBottom]: prefer.s.notificationPosition === 'leftBottom',
+		[$style.notificationsPosition_rightTop]: prefer.s.notificationPosition === 'rightTop',
+		[$style.notificationsPosition_rightBottom]: prefer.s.notificationPosition === 'rightBottom',
+		[$style.notificationsStackAxis_vertical]: prefer.s.notificationStackAxis === 'vertical',
+		[$style.notificationsStackAxis_horizontal]: prefer.s.notificationStackAxis === 'horizontal',
 	}]"
-	:moveClass="defaultStore.state.animation ? $style.transition_notification_move : ''"
-	:enterActiveClass="defaultStore.state.animation ? $style.transition_notification_enterActive : ''"
-	:leaveActiveClass="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''"
-	:enterFromClass="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''"
-	:leaveToClass="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''"
+	:moveClass="prefer.s.animation ? $style.transition_notification_move : ''"
+	:enterActiveClass="prefer.s.animation ? $style.transition_notification_enterActive : ''"
+	:leaveActiveClass="prefer.s.animation ? $style.transition_notification_leaveActive : ''"
+	:enterFromClass="prefer.s.animation ? $style.transition_notification_enterFrom : ''"
+	:leaveToClass="prefer.s.animation ? $style.transition_notification_leaveTo : ''"
 >
 	<div v-for="notification in notifications" :key="notification.id" :class="$style.notification">
 		<XNotification :notification="notification"/>
@@ -56,7 +56,7 @@ import * as sound from '@/scripts/sound.js';
 import { $i } from '@/account.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { globalEvents } from '@/events.js';
 
 const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 44253e93bd..698e9d8d47 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -54,11 +54,11 @@ import { openInstanceMenu } from './common.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 
-const menu = toRef(defaultStore.state, 'menu');
+const menu = toRef(prefer.s, 'menu');
 const otherMenuItemIndicated = computed(() => {
 	for (const def in navbarItemDef) {
 		if (menu.value.includes(def)) continue;
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index fec8666dc1..1fb99f9f22 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -94,20 +94,21 @@ import { openInstanceMenu } from './common.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
 import { useRouter } from '@/router/supplier.js';
+import { prefer } from '@/preferences.js';
 
 const router = useRouter();
 
 const forceIconOnly = ref(window.innerWidth <= 1279);
 const iconOnly = computed(() => {
-	return forceIconOnly.value || (defaultStore.reactiveState.menuDisplay.value === 'sideIcon');
+	return forceIconOnly.value || (store.reactiveState.menuDisplay.value === 'sideIcon');
 });
 
-const menu = computed(() => defaultStore.state.menu);
+const menu = computed(() => prefer.s.menu);
 const otherMenuItemIndicated = computed(() => {
 	for (const def in navbarItemDef) {
 		if (menu.value.includes(def)) continue;
@@ -122,12 +123,12 @@ function calcViewState() {
 
 window.addEventListener('resize', calcViewState);
 
-watch(defaultStore.reactiveState.menuDisplay, () => {
+watch(store.reactiveState.menuDisplay, () => {
 	calcViewState();
 });
 
 function toggleIconOnly() {
-	defaultStore.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon');
+	store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon');
 }
 
 function openAccountMenu(ev: MouseEvent) {
diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue
index ed881bef22..a8d87599e6 100644
--- a/packages/frontend/src/ui/_common_/statusbars.vue
+++ b/packages/frontend/src/ui/_common_/statusbars.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div :class="$style.root">
 	<div
-		v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black,
+		v-for="x in prefer.r.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black,
 			[$style.verySmall]: x.size === 'verySmall',
 			[$style.small]: x.size === 'small',
 			[$style.large]: x.size === 'large',
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent } from 'vue';
 import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue'));
 const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue'));
 const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue
index cc62a28b14..1eb809d198 100644
--- a/packages/frontend/src/ui/_common_/stream-indicator.vue
+++ b/packages/frontend/src/ui/_common_/stream-indicator.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div v-if="hasDisconnected && defaultStore.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected">
+<div v-if="hasDisconnected && prefer.s.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected">
 	<div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.disconnectedFromServer }}</div>
 	<div :class="$style.command" class="_buttons">
 		<MkButton small primary @click="reload">{{ i18n.ts.reload }}</MkButton>
@@ -19,7 +19,7 @@ import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const zIndex = os.claimZIndex('high');
 
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index f4633314ae..39b40754ff 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -53,15 +53,16 @@ import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
 
 const WINDOW_THRESHOLD = 1400;
 
 const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD);
-const menu = ref(defaultStore.state.menu);
-// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+const menu = ref(prefer.s.menu);
+// const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
 const otherNavItemIndicated = computed<boolean>(() => {
 	for (const def in navbarItemDef) {
 		if (menu.value.includes(def)) continue;
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 5acef0bef8..c11771f028 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -59,14 +59,15 @@ import MkButton from '@/components/MkButton.vue';
 // import { StickySidebar } from '@/scripts/sticky-sidebar.js';
 // import { mainRouter } from '@/router.js';
 //import MisskeyLogo from '@assets/client/misskey.svg';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
 
 const WINDOW_THRESHOLD = 1400;
 
-const menu = ref(defaultStore.state.menu);
-const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+const menu = ref(prefer.s.menu);
+const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
 const otherNavItemIndicated = computed<boolean>(() => {
 	for (const def in navbarItemDef) {
 		if (menu.value.includes(def)) continue;
@@ -99,7 +100,7 @@ function openAccountMenu(ev: MouseEvent) {
 	}, ev);
 }
 
-watch(defaultStore.reactiveState.menuDisplay, () => {
+watch(store.reactiveState.menuDisplay, () => {
 	calcViewState();
 });
 
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index da5059bb59..63c60a3d6f 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 	</div>
 
-	<Transition :name="defaultStore.state.animation ? 'tray-back' : ''">
+	<Transition :name="prefer.s.animation ? 'tray-back' : ''">
 		<div
 			v-if="widgetsShowing"
 			class="tray-back _modalBg"
@@ -35,11 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		></div>
 	</Transition>
 
-	<Transition :name="defaultStore.state.animation ? 'tray' : ''">
+	<Transition :name="prefer.s.animation ? 'tray' : ''">
 		<XWidgets v-if="widgetsShowing" class="tray"/>
 	</Transition>
 
-	<iframe v-if="defaultStore.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
+	<iframe v-if="prefer.s.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
 
 	<XCommon/>
 </div>
@@ -47,18 +47,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef } from 'vue';
+import { instanceName } from '@@/js/config.js';
+import { isLink } from '@@/js/is-link.js';
 import XSidebar from './classic.sidebar.vue';
 import XCommon from './_common_/common.vue';
-import { instanceName } from '@@/js/config.js';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
 import { StickySidebar } from '@/scripts/sticky-sidebar.js';
 import * as os from '@/os.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { mainRouter } from '@/router/main.js';
-import { isLink } from '@@/js/is-link.js';
+import { prefer } from '@/preferences.js';
 
 const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
 const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
@@ -74,7 +75,7 @@ const widgetsShowing = ref(false);
 const fullView = ref(false);
 const globalHeaderHeight = ref(0);
 const wallpaper = miLocalStorage.getItem('wallpaper') != null;
-const showMenuOnTop = computed(() => defaultStore.state.menuDisplay === 'top');
+const showMenuOnTop = computed(() => store.state.menuDisplay === 'top');
 const live2d = shallowRef<HTMLIFrameElement>();
 const widgetsLeft = ref<HTMLElement>();
 const widgetsRight = ref<HTMLElement>();
@@ -96,7 +97,7 @@ provide('shouldHeaderThin', showMenuOnTop.value);
 provide('forceSpacerMin', true);
 
 function attachSticky(el: HTMLElement) {
-	const sticky = new StickySidebar(el, 0, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
+	const sticky = new StickySidebar(el, 0, store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
 	window.addEventListener('scroll', () => {
 		sticky.calc(window.scrollY);
 	}, { passive: true });
@@ -142,9 +143,9 @@ if (window.innerWidth < 1024) {
 
 document.documentElement.style.overflowY = 'scroll';
 
-defaultStore.loaded.then(() => {
-	if (defaultStore.state.widgets.length === 0) {
-		defaultStore.set('widgets', [{
+store.loaded.then(() => {
+	if (store.state.widgets.length === 0) {
+		store.set('widgets', [{
 			name: 'calendar',
 			id: 'a', place: null, data: {},
 		}, {
@@ -162,7 +163,7 @@ onMounted(() => {
 		isDesktop.value = (window.innerWidth >= DESKTOP_THRESHOLD);
 	}, { passive: true });
 
-	if (defaultStore.state.aiChanMode) {
+	if (prefer.s.aiChanMode) {
 		const iframeRect = live2d.value.getBoundingClientRect();
 		window.addEventListener('mousemove', ev => {
 			live2d.value.contentWindow.postMessage({
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 8e99c457ad..2107f6f53a 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.main">
 		<XAnnouncements v-if="$i"/>
 		<XStatusBars/>
-		<div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel">
+		<div ref="columnsEl" :class="[$style.sections, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel">
 			<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
 			<section
 				v-for="ids in layout"
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 			<div :class="$style.sideMenu">
 				<div :class="$style.sideMenuTop">
-					<button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" :class="$style.sideMenuButton" class="_button" @click="changeProfile"><i class="ti ti-caret-down"></i></button>
+					<button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${store.state['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="changeProfile"><i class="ti ti-caret-down"></i></button>
 					<button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.sideMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button>
 				</div>
 				<div :class="$style.sideMenuMiddle">
@@ -62,10 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</div>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
 	>
 		<div
 			v-if="drawerMenuShowing"
@@ -77,10 +77,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</Transition>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''"
 	>
 		<div v-if="drawerMenuShowing" :class="$style.menu">
 			<XDrawerMenu/>
@@ -95,8 +95,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
 import { v4 as uuid } from 'uuid';
 import XCommon from './_common_/common.vue';
-import { deckStore, columnTypes, addColumn as addColumnToStore, forceSaveDeck, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
-import type { ColumnType } from './deck/deck-store.js';
 import type { MenuItem } from '@/types/menu.js';
 import XSidebar from '@/ui/_common_/navbar.vue';
 import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
@@ -107,7 +105,7 @@ import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import { unisonReload } from '@/scripts/unison-reload.js';
 import { deviceKind } from '@/scripts/device-kind.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import XMainColumn from '@/ui/deck/main-column.vue';
 import XTlColumn from '@/ui/deck/tl-column.vue';
 import XAntennaColumn from '@/ui/deck/antenna-column.vue';
@@ -119,6 +117,8 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
 import XDirectColumn from '@/ui/deck/direct-column.vue';
 import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
 import { mainRouter } from '@/router/main.js';
+import { store } from '@/store.js';
+import { columnTypes, forceSaveDeck, getProfiles, loadDeck, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
 const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
 const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
 
@@ -137,8 +137,8 @@ const columnComponents = {
 
 mainRouter.navHook = (path, flag): boolean => {
 	if (flag === 'forcePage') return false;
-	const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main');
-	if (deckStore.state.navWindow || noMainColumn) {
+	const noMainColumn = !store.state['deck.columns'].some(x => x.type === 'main');
+	if (prefer.s['deck.navWindow'] || noMainColumn) {
 		os.pageWindow(path);
 		return true;
 	}
@@ -160,8 +160,8 @@ watch(route, () => {
 });
 */
 
-const columns = deckStore.reactiveState.columns;
-const layout = deckStore.reactiveState.layout;
+const columns = store.reactiveState['deck.columns'];
+const layout = store.reactiveState['deck.layout'];
 const menuIndicated = computed(() => {
 	if ($i == null) return false;
 	for (const def in navbarItemDef) {
@@ -214,15 +214,15 @@ loadDeck();
 
 function changeProfile(ev: MouseEvent) {
 	let items: MenuItem[] = [{
-		text: deckStore.state.profile,
+		text: store.state['deck.profile'],
 		active: true,
 		action: () => {},
 	}];
 	getProfiles().then(profiles => {
-		items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
+		items.push(...(profiles.filter(k => k !== store.state['deck.profile']).map(k => ({
 			text: k,
 			action: () => {
-				deckStore.set('profile', k);
+				store.set('deck.profile', k);
 				unisonReload();
 			},
 		}))), { type: 'divider' as const }, {
@@ -237,7 +237,7 @@ function changeProfile(ev: MouseEvent) {
 				if (canceled || name == null) return;
 
 				os.promiseDialog((async () => {
-					await deckStore.set('profile', name);
+					await store.set('deck.profile', name);
 					await forceSaveDeck();
 				})(), () => {
 					unisonReload();
@@ -252,19 +252,19 @@ function changeProfile(ev: MouseEvent) {
 async function deleteProfile() {
 	const { canceled } = await os.confirm({
 		type: 'warning',
-		text: i18n.tsx.deleteAreYouSure({ x: deckStore.state.profile }),
+		text: i18n.tsx.deleteAreYouSure({ x: store.state['deck.profile'] }),
 	});
 	if (canceled) return;
 
 	os.promiseDialog((async () => {
-		if (deckStore.state.profile === 'default') {
-			await deckStore.set('columns', []);
-			await deckStore.set('layout', []);
+		if (store.state['deck.profile'] === 'default') {
+			await store.set('deck.columns', []);
+			await store.set('deck.layout', []);
 			await forceSaveDeck();
 		} else {
-			await deleteProfile_(deckStore.state.profile);
+			await deleteProfile_(store.state['deck.profile']);
 		}
-		await deckStore.set('profile', 'default');
+		await store.set('deck.profile', 'default');
 	})(), () => {
 		unisonReload();
 	});
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index b79cd8408b..5f0c607edb 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -15,17 +15,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue';
-import type { entities as MisskeyEntities } from 'misskey-js';
 import XColumn from './column.vue';
-import { updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import type { entities as MisskeyEntities } from 'misskey-js';
+import type { Column } from '@/deck.js';
+import type { MenuItem } from '@/types/menu.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import type { MenuItem } from '@/types/menu.js';
 import { antennasCache } from '@/cache.js';
-import type { SoundStore } from '@/store.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
 
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 9e07c06639..f002f655f7 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -22,16 +22,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import XColumn from './column.vue';
-import { updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import type { Column } from '@/deck.js';
+import type { MenuItem } from '@/types/menu.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { favoritedChannelsCache } from '@/cache.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import type { MenuItem } from '@/types/menu.js';
-import type { SoundStore } from '@/store.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
 
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index f23e33c748..fc208197a0 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -43,11 +43,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed } from 'vue';
-import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store.js';
+import type { Column } from '@/deck.js';
+import type { MenuItem } from '@/types/menu.js';
+import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import type { Column } from './deck-store.js';
-import type { MenuItem } from '@/types/menu.js';
 
 provide('shouldHeaderThin', true);
 provide('shouldOmitHeaderTitle', true);
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 9055ea6d43..bdca513a7a 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -3,57 +3,11 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { throttle } from 'throttle-debounce';
 import { markRaw } from 'vue';
-import { notificationTypes } from 'misskey-js';
-import type { BasicTimelineType } from '@/timelines.js';
+import type { Column } from '@/deck.js';
 import { Storage } from '@/pizzax.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { deepClone } from '@/scripts/clone.js';
-import type { SoundStore } from '@/store.js';
-
-type ColumnWidget = {
-	name: string;
-	id: string;
-	data: Record<string, any>;
-};
-
-export const columnTypes = [
-	'main',
-	'widgets',
-	'notifications',
-	'tl',
-	'antenna',
-	'list',
-	'channel',
-	'mentions',
-	'direct',
-	'roleTimeline',
-] as const;
-
-export type ColumnType = typeof columnTypes[number];
-
-export type Column = {
-	id: string;
-	type: ColumnType;
-	name: string | null;
-	width: number;
-	widgets?: ColumnWidget[];
-	active?: boolean;
-	flexible?: boolean;
-	antennaId?: string;
-	listId?: string;
-	channelId?: string;
-	roleId?: string;
-	excludeTypes?: typeof notificationTypes[number][];
-	tl?: BasicTimelineType;
-	withRenotes?: boolean;
-	withReplies?: boolean;
-	withSensitive?: boolean;
-	onlyFiles?: boolean;
-	soundSetting?: SoundStore;
-};
 
+// TODO: 消す(移行済みのため)
 export const deckStore = markRaw(new Storage('deck', {
 	profile: {
 		where: 'deviceAccount',
@@ -67,272 +21,4 @@ export const deckStore = markRaw(new Storage('deck', {
 		where: 'deviceAccount',
 		default: [] as Column['id'][][],
 	},
-	columnAlign: {
-		where: 'deviceAccount',
-		default: 'left' as 'left' | 'right' | 'center',
-	},
-	alwaysShowMainColumn: {
-		where: 'deviceAccount',
-		default: true,
-	},
-	navWindow: {
-		where: 'deviceAccount',
-		default: true,
-	},
-	useSimpleUiForNonRootPages: {
-		where: 'deviceAccount',
-		default: true,
-	},
 }));
-
-export const loadDeck = async () => {
-	let deck;
-
-	try {
-		deck = await misskeyApi('i/registry/get', {
-			scope: ['client', 'deck', 'profiles'],
-			key: deckStore.state.profile,
-		});
-	} catch (err) {
-		if (typeof err === 'object' && err != null && 'code' in err && err.code === 'NO_SUCH_KEY') {
-			// 後方互換性のため
-			if (deckStore.state.profile === 'default') {
-				saveDeck();
-				return;
-			}
-
-			deckStore.set('columns', []);
-			deckStore.set('layout', []);
-			return;
-		}
-		throw err;
-	}
-
-	deckStore.set('columns', deck.columns);
-	deckStore.set('layout', deck.layout);
-};
-
-export async function forceSaveDeck() {
-	await misskeyApi('i/registry/set', {
-		scope: ['client', 'deck', 'profiles'],
-		key: deckStore.state.profile,
-		value: {
-			columns: deckStore.reactiveState.columns.value,
-			layout: deckStore.reactiveState.layout.value,
-		},
-	});
-}
-
-// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
-export const saveDeck = throttle(1000, () => {
-	forceSaveDeck();
-});
-
-export async function getProfiles(): Promise<string[]> {
-	return await misskeyApi('i/registry/keys', {
-		scope: ['client', 'deck', 'profiles'],
-	});
-}
-
-export async function deleteProfile(key: string): Promise<void> {
-	return await misskeyApi('i/registry/remove', {
-		scope: ['client', 'deck', 'profiles'],
-		key: key,
-	});
-}
-
-export function addColumn(column: Column) {
-	if (column.name === undefined) column.name = null;
-	deckStore.push('columns', column);
-	deckStore.push('layout', [column.id]);
-	saveDeck();
-}
-
-export function removeColumn(id: Column['id']) {
-	deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id));
-	deckStore.set('layout', deckStore.state.layout
-		.map(ids => ids.filter(_id => _id !== id))
-		.filter(ids => ids.length > 0));
-	saveDeck();
-}
-
-export function swapColumn(a: Column['id'], b: Column['id']) {
-	const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1);
-	const aY = deckStore.state.layout[aX].findIndex(id => id === a);
-	const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1);
-	const bY = deckStore.state.layout[bX].findIndex(id => id === b);
-	const layout = deepClone(deckStore.state.layout);
-	layout[aX][aY] = b;
-	layout[bX][bY] = a;
-	deckStore.set('layout', layout);
-	saveDeck();
-}
-
-export function swapLeftColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	deckStore.state.layout.some((ids, i) => {
-		if (ids.includes(id)) {
-			const left = deckStore.state.layout[i - 1];
-			if (left) {
-				layout[i - 1] = deckStore.state.layout[i];
-				layout[i] = left;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-		return false;
-	});
-	saveDeck();
-}
-
-export function swapRightColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	deckStore.state.layout.some((ids, i) => {
-		if (ids.includes(id)) {
-			const right = deckStore.state.layout[i + 1];
-			if (right) {
-				layout[i + 1] = deckStore.state.layout[i];
-				layout[i] = right;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-		return false;
-	});
-	saveDeck();
-}
-
-export function swapUpColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const ids = deepClone(deckStore.state.layout[idsIndex]);
-	ids.some((x, i) => {
-		if (x === id) {
-			const up = ids[i - 1];
-			if (up) {
-				ids[i - 1] = id;
-				ids[i] = up;
-
-				layout[idsIndex] = ids;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-		return false;
-	});
-	saveDeck();
-}
-
-export function swapDownColumn(id: Column['id']) {
-	const layout = deepClone(deckStore.state.layout);
-	const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const ids = deepClone(deckStore.state.layout[idsIndex]);
-	ids.some((x, i) => {
-		if (x === id) {
-			const down = ids[i + 1];
-			if (down) {
-				ids[i + 1] = id;
-				ids[i] = down;
-
-				layout[idsIndex] = ids;
-				deckStore.set('layout', layout);
-			}
-			return true;
-		}
-		return false;
-	});
-	saveDeck();
-}
-
-export function stackLeftColumn(id: Column['id']) {
-	let layout = deepClone(deckStore.state.layout);
-	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	layout = layout.map(ids => ids.filter(_id => _id !== id));
-	layout[i - 1].push(id);
-	layout = layout.filter(ids => ids.length > 0);
-	deckStore.set('layout', layout);
-	saveDeck();
-}
-
-export function popRightColumn(id: Column['id']) {
-	let layout = deepClone(deckStore.state.layout);
-	const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
-	const affected = layout[i];
-	layout = layout.map(ids => ids.filter(_id => _id !== id));
-	layout.splice(i + 1, 0, [id]);
-	layout = layout.filter(ids => ids.length > 0);
-	deckStore.set('layout', layout);
-
-	const columns = deepClone(deckStore.state.columns);
-	for (const column of columns) {
-		if (affected.includes(column.id)) {
-			column.active = true;
-		}
-	}
-	deckStore.set('columns', columns);
-
-	saveDeck();
-}
-
-export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	if (column.widgets == null) column.widgets = [];
-	column.widgets.unshift(widget);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	if (column.widgets == null) column.widgets = [];
-	column.widgets = column.widgets.filter(w => w.id !== widget.id);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	column.widgets = widgets;
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
-	if (column.widgets == null) column.widgets = [];
-	column.widgets = column.widgets.map(w => w.id === widgetId ? {
-		...w,
-		data: widgetData,
-	} : w);
-	columns[columnIndex] = column;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
-
-export function updateColumn(id: Column['id'], column: Partial<Column>) {
-	const columns = deepClone(deckStore.state.columns);
-	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
-	const currentColumn = deepClone(deckStore.state.columns[columnIndex]);
-	if (currentColumn == null) return;
-	for (const [k, v] of Object.entries(column)) {
-		currentColumn[k] = v;
-	}
-	columns[columnIndex] = currentColumn;
-	deckStore.set('columns', columns);
-	saveDeck();
-}
diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue
index 2cecd6c669..772188d773 100644
--- a/packages/frontend/src/ui/deck/direct-column.vue
+++ b/packages/frontend/src/ui/deck/direct-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import XColumn from './column.vue';
-import type { Column } from './deck-store.js';
+import type { Column } from '@/deck.js';
 import MkNotes from '@/components/MkNotes.vue';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 83961d02bc..f627ab5262 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -15,16 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { watch, shallowRef, ref, onMounted } from 'vue';
-import type { entities as MisskeyEntities } from 'misskey-js';
 import XColumn from './column.vue';
-import { updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import type { entities as MisskeyEntities } from 'misskey-js';
+import type { Column } from '@/deck.js';
+import type { MenuItem } from '@/types/menu.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import type { MenuItem } from '@/types/menu.js';
-import type { SoundStore } from '@/store.js';
 import { userListsCache } from '@/cache.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index 45c39a5cad..906e94f0b3 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked">
+<XColumn v-if="prefer.s['deck.alwaysShowMainColumn'] || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked">
 	<template #header>
 		<template v-if="pageMetadata">
 			<i :class="pageMetadata.icon"></i>
@@ -20,17 +20,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { provide, shallowRef, ref } from 'vue';
+import { getScrollContainer } from '@@/js/scroll.js';
+import { isLink } from '@@/js/is-link.js';
 import XColumn from './column.vue';
-import { deckStore } from '@/ui/deck/deck-store.js';
-import type { Column } from '@/ui/deck/deck-store.js';
+import type { Column } from '@/deck.js';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
 import { useScrollPositionManager } from '@/nirax.js';
-import { getScrollContainer } from '@@/js/scroll.js';
-import { isLink } from '@@/js/is-link.js';
 import { mainRouter } from '@/router/main.js';
+import { prefer } from '@/preferences.js';
 
 defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue
index 233fba554b..ffd0307940 100644
--- a/packages/frontend/src/ui/deck/mentions-column.vue
+++ b/packages/frontend/src/ui/deck/mentions-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import XColumn from './column.vue';
-import type { Column } from './deck-store.js';
+import type { Column } from '@/deck.js';
 import MkNotes from '@/components/MkNotes.vue';
 import { i18n } from '../../i18n.js';
 
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
index c0303e86dc..0a2c0e9952 100644
--- a/packages/frontend/src/ui/deck/notifications-column.vue
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent, shallowRef } from 'vue';
 import XColumn from './column.vue';
-import { updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import { updateColumn } from '@/deck.js';
+import type { Column } from '@/deck.js';
 import XNotifications from '@/components/MkNotifications.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 5b1420570d..4d1be08706 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -16,14 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, onMounted, ref, shallowRef, watch } from 'vue';
 import XColumn from './column.vue';
-import { updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import type { Column } from '@/deck.js';
+import type { MenuItem } from '@/types/menu.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import type { MenuItem } from '@/types/menu.js';
-import type { SoundStore } from '@/store.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
 
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index b9b3746abf..96da944398 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -34,15 +34,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, watch, ref, shallowRef, computed } from 'vue';
 import XColumn from './column.vue';
-import { removeColumn, updateColumn } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
+import type { SoundStore } from '@/preferences/def.js';
+import { removeColumn, updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
-import { instance } from '@/instance.js';
-import type { SoundStore } from '@/store.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
 
diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts
index 03d4b3a580..277ebb0511 100644
--- a/packages/frontend/src/ui/deck/tl-note-notification.ts
+++ b/packages/frontend/src/ui/deck/tl-note-notification.ts
@@ -5,8 +5,8 @@
 
 import * as Misskey from 'misskey-js';
 import type { Ref } from 'vue';
-import type { SoundStore } from '@/store.js';
 import type { SoundType } from '@/scripts/sound.js';
+import type { SoundStore } from '@/preferences/def.js';
 import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/scripts/sound.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue
index 20284d8c9f..4e84ef0ba0 100644
--- a/packages/frontend/src/ui/deck/widgets-column.vue
+++ b/packages/frontend/src/ui/deck/widgets-column.vue
@@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import XColumn from './column.vue';
-import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store.js';
-import type { Column } from './deck-store.js';
+import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from '@/deck.js';
+import type { Column } from '@/deck.js';
 import XWidgets from '@/components/MkWidgets.vue';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 25f47a2d55..bddb62dc60 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu">
 		<template #header>
 			<div>
+				<XPreferenceRestore v-if="shouldSuggestRestoreBackup"/>
 				<XAnnouncements v-if="$i"/>
 				<XStatusBars :class="$style.statusbars"/>
 			</div>
@@ -38,10 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</div>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
 	>
 		<div
 			v-if="drawerMenuShowing"
@@ -53,10 +54,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</Transition>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''"
 	>
 		<div v-if="drawerMenuShowing" :class="$style.menuDrawer">
 			<XDrawerMenu/>
@@ -64,10 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</Transition>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''"
 	>
 		<div
 			v-if="widgetsShowing"
@@ -79,10 +80,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</Transition>
 
 	<Transition
-		:enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''"
-		:leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''"
-		:enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''"
-		:leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
+		:enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterActive : ''"
+		:leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveActive : ''"
+		:enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterFrom : ''"
+		:leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
 	>
 		<div v-if="widgetsShowing" :class="$style.widgetsDrawer">
 			<button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button>
@@ -105,7 +106,7 @@ import type MkStickyContainer from '@/components/global/MkStickyContainer.vue';
 import type { PageMetadata } from '@/scripts/page-metadata.js';
 import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
 import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { navbarItemDef } from '@/navbar.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
@@ -114,11 +115,14 @@ import { deviceKind } from '@/scripts/device-kind.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { useScrollPositionManager } from '@/nirax.js';
 import { mainRouter } from '@/router/main.js';
+import { prefer } from '@/preferences.js';
+import { shouldSuggestRestoreBackup } from '@/preferences/utility.js';
 
 const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
 const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
 const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
 const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
+const XPreferenceRestore = defineAsyncComponent(() => import('@/ui/_common_/PreferenceRestore.vue'));
 
 const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
 
@@ -174,9 +178,9 @@ if (window.innerWidth > 1024) {
 	}
 }
 
-defaultStore.loaded.then(() => {
-	if (defaultStore.state.widgets.length === 0) {
-		defaultStore.set('widgets', [{
+store.loaded.then(() => {
+	if (store.state.widgets.length === 0) {
+		store.set('widgets', [{
 			name: 'calendar',
 			id: 'a', place: 'right', data: {},
 		}, {
diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue
index fc0a4475d2..42b870cbb5 100644
--- a/packages/frontend/src/ui/universal.widgets.vue
+++ b/packages/frontend/src/ui/universal.widgets.vue
@@ -19,7 +19,7 @@ const editMode = ref(false);
 <script lang="ts" setup>
 import XWidgets from '@/components/MkWidgets.vue';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 
 const props = withDefaults(defineProps<{
 	// null = 全てのウィジェットを表示
@@ -31,24 +31,24 @@ const props = withDefaults(defineProps<{
 });
 
 const widgets = computed(() => {
-	if (props.place === null) return defaultStore.reactiveState.widgets.value;
-	if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left');
-	return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left');
+	if (props.place === null) return store.reactiveState.widgets.value;
+	if (props.place === 'left') return store.reactiveState.widgets.value.filter(w => w.place === 'left');
+	return store.reactiveState.widgets.value.filter(w => w.place !== 'left');
 });
 
 function addWidget(widget) {
-	defaultStore.set('widgets', [{
+	store.set('widgets', [{
 		...widget,
 		place: props.place,
-	}, ...defaultStore.state.widgets]);
+	}, ...store.state.widgets]);
 }
 
 function removeWidget(widget) {
-	defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id));
+	store.set('widgets', store.state.widgets.filter(w => w.id !== widget.id));
 }
 
 function updateWidget({ id, data }) {
-	defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? {
+	store.set('widgets', store.state.widgets.map(w => w.id === id ? {
 		...w,
 		data,
 		place: props.place,
@@ -57,18 +57,18 @@ function updateWidget({ id, data }) {
 
 function updateWidgets(thisWidgets) {
 	if (props.place === null) {
-		defaultStore.set('widgets', thisWidgets);
+		store.set('widgets', thisWidgets);
 		return;
 	}
 	if (props.place === 'left') {
-		defaultStore.set('widgets', [
+		store.set('widgets', [
 			...thisWidgets.map(w => ({ ...w, place: 'left' })),
-			...defaultStore.state.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)),
+			...store.state.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)),
 		]);
 		return;
 	}
-	defaultStore.set('widgets', [
-		...defaultStore.state.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)),
+	store.set('widgets', [
+		...store.state.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)),
 		...thisWidgets.map(w => ({ ...w, place: 'right' })),
 	]);
 }
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 8bcb260677..9ba9786ca8 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -70,15 +70,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, provide, ref, computed } from 'vue';
-import XCommon from './_common_/common.vue';
 import { instanceName } from '@@/js/config.js';
+import XCommon from './_common_/common.vue';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
 import * as os from '@/os.js';
 import { instance } from '@/instance.js';
 import XSigninDialog from '@/components/MkSigninDialog.vue';
 import XSignupDialog from '@/components/MkSignupDialog.vue';
-import { ColdDeviceStorage, defaultStore } from '@/store.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
 import { mainRouter } from '@/router/main.js';
@@ -108,7 +107,7 @@ const announcements = {
 	limit: 10,
 };
 
-const isTimelineAvailable = ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable);
+const isTimelineAvailable = ref(instance.policies.ltlAvailable || instance.policies.gtlAvailable);
 
 const showMenu = ref(false);
 const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
@@ -116,10 +115,6 @@ const narrow = ref(window.innerWidth < 1280);
 
 const keymap = computed(() => {
 	return {
-		'd': () => {
-			if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
-			defaultStore.set('darkMode', !defaultStore.state.darkMode);
-		},
 		's': () => {
 			mainRouter.push('/search');
 		},
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue
index 89716575a9..6e56c9115e 100644
--- a/packages/frontend/src/widgets/WidgetFederation.vue
+++ b/packages/frontend/src/widgets/WidgetFederation.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<div class="wbrkwalb">
 		<MkLoading v-if="fetching"/>
-		<TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="instances">
+		<TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="instances">
 			<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
 				<img :src="getInstanceIcon(instance)" alt=""/>
 				<div class="body">
@@ -27,16 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkMiniChart from '@/components/MkMiniChart.vue';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const name = 'federation';
 
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index 84ba05b5d3..00d0ec7fa5 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -59,7 +59,7 @@ import { useStream } from '@/stream.js';
 import kmg from '@/filters/kmg.js';
 import * as sound from '@/scripts/sound.js';
 import { deepClone } from '@/scripts/clone.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const name = 'jobQueue';
 
@@ -104,7 +104,7 @@ const prev = reactive({} as typeof current);
 const jammedAudioBuffer = ref<AudioBuffer | null>(null);
 const jammedSoundNodePlaying = ref<boolean>(false);
 
-if (defaultStore.state.sound_masterVolume) {
+if (prefer.s['sound.masterVolume']) {
 	sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => {
 		if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
 		jammedAudioBuffer.value = buf;
diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue
index 65ab7a7075..4705de2016 100644
--- a/packages/frontend/src/widgets/WidgetMemo.vue
+++ b/packages/frontend/src/widgets/WidgetMemo.vue
@@ -21,7 +21,7 @@ import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'memo';
@@ -48,12 +48,12 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
-const text = ref<string | null>(defaultStore.state.memo);
+const text = ref<string | null>(store.state.memo);
 const changed = ref(false);
 let timeoutId;
 
 const saveMemo = () => {
-	defaultStore.set('memo', text.value);
+	store.set('memo', text.value);
 	changed.value = false;
 };
 
@@ -63,7 +63,7 @@ const onChange = () => {
 	timeoutId = window.setTimeout(saveMemo, 1000);
 };
 
-watch(() => defaultStore.reactiveState.memo, newText => {
+watch(() => store.reactiveState.memo, newText => {
 	text.value = newText.value;
 });
 
diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue
index 6d13ba09cc..6c7c82f229 100644
--- a/packages/frontend/src/widgets/WidgetPhotos.vue
+++ b/packages/frontend/src/widgets/WidgetPhotos.vue
@@ -31,7 +31,7 @@ import { useStream } from '@/stream.js';
 import { getStaticImageUrl } from '@/scripts/media-proxy.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'photos';
@@ -70,7 +70,7 @@ const onDriveFileCreated = (file) => {
 };
 
 const thumbnail = (image: Misskey.entities.DriveFile): string => {
-	return defaultStore.state.disableShowingAnimatedImages
+	return prefer.s.disableShowingAnimatedImages
 		? getStaticImageUrl(image.url)
 		: image.thumbnailUrl ?? image.url;
 };
diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue
index 3354912c07..1e3ca74d40 100644
--- a/packages/frontend/src/widgets/WidgetTrends.vue
+++ b/packages/frontend/src/widgets/WidgetTrends.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<div class="wbrkwala">
 		<MkLoading v-if="fetching"/>
-		<TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="tags">
+		<TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="tags">
 			<div v-for="stat in stats" :key="stat.tag">
 				<div class="tag">
 					<MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
@@ -26,15 +26,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkMiniChart from '@/components/MkMiniChart.vue';
 import { misskeyApiGet } from '@/scripts/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 const name = 'hashtags';
 
diff --git a/packages/frontend/test/emoji.test.ts b/packages/frontend/test/emoji.test.ts
index cf686efd0d..ffdc858b75 100644
--- a/packages/frontend/test/emoji.test.ts
+++ b/packages/frontend/test/emoji.test.ts
@@ -5,7 +5,7 @@
 
 import { describe, test, assert, afterEach } from 'vitest';
 import { render, cleanup, type RenderResult } from '@testing-library/vue';
-import { defaultStoreState } from './init.js';
+import { preferState } from './init.js';
 import { getEmojiName } from '@@/js/emojilist.js';
 import { components } from '@/components/index.js';
 import { directives } from '@/directives/index.js';
@@ -21,12 +21,12 @@ describe('Emoji', () => {
 
 	afterEach(() => {
 		cleanup();
-		defaultStoreState.emojiStyle = '';
+		preferState.emojiStyle = '';
 	});
 
 	describe('MkEmoji', () => {
 		test('Should render selector-less heart with color in native mode', async () => {
-			defaultStoreState.emojiStyle = 'native';
+			preferState.emojiStyle = 'native';
 			const mkEmoji = await renderEmoji('\u2764'); // monochrome heart
 			assert.ok(mkEmoji.queryByText('\u2764\uFE0F')); // colored heart
 			assert.ok(!mkEmoji.queryByText('\u2764'));
diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts
index 0cde571dcb..3b6b4d581b 100644
--- a/packages/frontend/test/init.ts
+++ b/packages/frontend/test/init.ts
@@ -17,7 +17,7 @@ updateI18n(locales['en-US']);
 // XXX: misskey-js panics if WebSocket is not defined
 vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
 
-export const defaultStoreState: Record<string, unknown> = {
+export const preferState: Record<string, unknown> = {
 
 	// なんかtestがうまいこと動かないのでここに書く
 	dataSaver: {
@@ -29,11 +29,11 @@ export const defaultStoreState: Record<string, unknown> = {
 
 };
 
-// XXX: defaultStore somehow becomes undefined in vitest?
-vi.mock('@/store.js', () => {
+// XXX: store somehow becomes undefined in vitest?
+vi.mock('@/preferences.js', () => {
 	return {
-		defaultStore: {
-			state: defaultStoreState,
+		prefer: {
+			s: preferState,
 		},
 	};
 });
-- 
cgit v1.2.3-freya


From be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 9 Mar 2025 14:28:01 +0900
Subject: refactor(frontend): scripts -> utility

---
 packages/frontend/.storybook/preview.ts            |   4 +-
 packages/frontend/@types/theme.d.ts                |   2 +-
 packages/frontend/src/account.ts                   |   8 +-
 packages/frontend/src/aiscript/api.ts              | 125 ++++
 packages/frontend/src/aiscript/common.ts           |  16 +
 packages/frontend/src/aiscript/ui.ts               | 655 ++++++++++++++++
 packages/frontend/src/boot/common.ts               |  12 +-
 packages/frontend/src/boot/main-boot.ts            |  22 +-
 packages/frontend/src/boot/sub-boot.ts             |   2 +-
 packages/frontend/src/cache.ts                     |   4 +-
 packages/frontend/src/components/MkAbuseReport.vue |   2 +-
 .../frontend/src/components/MkAccountMoved.vue     |   2 +-
 .../src/components/MkAchievements.stories.impl.ts  |   2 +-
 .../frontend/src/components/MkAchievements.vue     |   4 +-
 packages/frontend/src/components/MkAnalogClock.vue |   2 +-
 .../src/components/MkAnnouncementDialog.vue        |   2 +-
 .../frontend/src/components/MkAntennaEditor.vue    |   6 +-
 packages/frontend/src/components/MkAsUi.vue        |   2 +-
 packages/frontend/src/components/MkAuthConfirm.vue |   4 +-
 .../src/components/MkAutocomplete.stories.impl.ts  |   2 +-
 .../frontend/src/components/MkAutocomplete.vue     |   8 +-
 packages/frontend/src/components/MkAvatars.vue     |   2 +-
 .../src/components/MkChannelFollowButton.vue       |   2 +-
 packages/frontend/src/components/MkChart.vue       |  12 +-
 packages/frontend/src/components/MkClickerGame.vue |   4 +-
 packages/frontend/src/components/MkCode.core.vue   |   2 +-
 packages/frontend/src/components/MkCode.vue        |   2 +-
 packages/frontend/src/components/MkContextMenu.vue |   2 +-
 .../frontend/src/components/MkCropperDialog.vue    |   2 +-
 packages/frontend/src/components/MkCwButton.vue    |   2 +-
 .../frontend/src/components/MkDigitalClock.vue     |   2 +-
 packages/frontend/src/components/MkDrive.file.vue  |   4 +-
 .../frontend/src/components/MkDrive.folder.vue     |   6 +-
 .../frontend/src/components/MkDrive.navFolder.vue  |   2 +-
 packages/frontend/src/components/MkDrive.vue       |   6 +-
 .../src/components/MkEmbedCodeGenDialog.vue        |   4 +-
 packages/frontend/src/components/MkEmojiPicker.vue |   6 +-
 .../frontend/src/components/MkFoldableSection.vue  |   2 +-
 packages/frontend/src/components/MkFolder.vue      |   2 +-
 .../frontend/src/components/MkFollowButton.vue     |   6 +-
 .../frontend/src/components/MkFormDialog.file.vue  |   4 +-
 packages/frontend/src/components/MkFormDialog.vue  |   2 +-
 packages/frontend/src/components/MkHeatmap.vue     |   8 +-
 .../frontend/src/components/MkHorizontalSwipe.vue  |   2 +-
 packages/frontend/src/components/MkInput.vue       |   4 +-
 .../frontend/src/components/MkInstanceCardMini.vue |   4 +-
 .../frontend/src/components/MkInstanceStats.vue    |   6 +-
 .../frontend/src/components/MkInstanceTicker.vue   |   2 +-
 packages/frontend/src/components/MkInviteCode.vue  |   2 +-
 packages/frontend/src/components/MkKeyValue.vue    |   2 +-
 packages/frontend/src/components/MkLaunchPad.vue   |   2 +-
 packages/frontend/src/components/MkLink.vue        |   2 +-
 packages/frontend/src/components/MkMediaAudio.vue  |   4 +-
 packages/frontend/src/components/MkMediaImage.vue  |   4 +-
 packages/frontend/src/components/MkMediaList.vue   |   2 +-
 packages/frontend/src/components/MkMediaVideo.vue  |   8 +-
 packages/frontend/src/components/MkMention.vue     |   2 +-
 packages/frontend/src/components/MkMenu.vue        |   8 +-
 packages/frontend/src/components/MkModal.vue       |  10 +-
 packages/frontend/src/components/MkNote.vue        |  34 +-
 .../frontend/src/components/MkNoteDetailed.vue     |  30 +-
 packages/frontend/src/components/MkNoteSub.vue     |   4 +-
 .../frontend/src/components/MkNotification.vue     |   4 +-
 packages/frontend/src/components/MkPageWindow.vue  |  10 +-
 packages/frontend/src/components/MkPagination.vue  |   2 +-
 packages/frontend/src/components/MkPoll.vue        |   8 +-
 packages/frontend/src/components/MkPollEditor.vue  |   4 +-
 packages/frontend/src/components/MkPostForm.vue    |  22 +-
 .../frontend/src/components/MkPostFormAttaches.vue |   4 +-
 .../frontend/src/components/MkPullToRefresh.vue    |   2 +-
 .../components/MkPushNotificationAllowButton.vue   |   2 +-
 packages/frontend/src/components/MkRange.vue       |   2 +-
 .../frontend/src/components/MkReactionIcon.vue     |   2 +-
 .../src/components/MkReactionsViewer.reaction.vue  |  10 +-
 .../frontend/src/components/MkRetentionHeatmap.vue |   8 +-
 .../src/components/MkRetentionLineChart.vue        |  10 +-
 .../frontend/src/components/MkRoleSelectDialog.vue |   2 +-
 .../frontend/src/components/MkSignin.input.vue     |   2 +-
 packages/frontend/src/components/MkSignin.vue      |   6 +-
 .../frontend/src/components/MkSigninDialog.vue     |   2 +-
 .../src/components/MkSignupDialog.form.vue         |   2 +-
 packages/frontend/src/components/MkSuperMenu.vue   |   4 +-
 .../src/components/MkSystemWebhookEditor.vue       |   2 +-
 packages/frontend/src/components/MkTextarea.vue    |   4 +-
 packages/frontend/src/components/MkTimeline.vue    |   2 +-
 packages/frontend/src/components/MkTooltip.vue     |   2 +-
 .../frontend/src/components/MkTutorialDialog.vue   |   2 +-
 packages/frontend/src/components/MkUpdated.vue     |   2 +-
 packages/frontend/src/components/MkUrlPreview.vue  |   4 +-
 .../components/MkUserAnnouncementEditDialog.vue    |   2 +-
 .../frontend/src/components/MkUserCardMini.vue     |   2 +-
 packages/frontend/src/components/MkUserInfo.vue    |   4 +-
 packages/frontend/src/components/MkUserPopup.vue   |   8 +-
 .../frontend/src/components/MkUserSelectDialog.vue |   2 +-
 .../src/components/MkUserSetupDialog.Privacy.vue   |   2 +-
 .../src/components/MkUserSetupDialog.Profile.vue   |   2 +-
 .../src/components/MkUserSetupDialog.User.vue      |   2 +-
 .../MkVisitorDashboard.ActiveUsersChart.vue        |   8 +-
 .../frontend/src/components/MkVisitorDashboard.vue |   2 +-
 packages/frontend/src/components/MkWindow.vue      |   2 +-
 .../frontend/src/components/MkYouTubePlayer.vue    |   2 +-
 .../src/components/global/MkA.stories.impl.ts      |   2 +-
 packages/frontend/src/components/global/MkA.vue    |   2 +-
 .../frontend/src/components/global/MkAvatar.vue    |   2 +-
 .../src/components/global/MkCustomEmoji.vue        |   8 +-
 .../frontend/src/components/global/MkEmoji.vue     |   4 +-
 .../src/components/global/MkPageHeader.vue         |   4 +-
 .../frontend/src/components/global/MkSpacer.vue    |   2 +-
 packages/frontend/src/components/global/MkUrl.vue  |   2 +-
 .../frontend/src/components/grid/MkDataCell.vue    |   2 +-
 packages/frontend/src/components/grid/MkGrid.vue   |   2 +-
 .../frontend/src/components/grid/grid-utils.ts     |   2 +-
 .../frontend/src/components/page/page.note.vue     |   2 +-
 .../frontend/src/components/page/page.text.vue     |   2 +-
 packages/frontend/src/custom-emojis.ts             |   4 +-
 packages/frontend/src/deck.ts                      |   4 +-
 packages/frontend/src/directives/adaptive-bg.ts    |   2 +-
 .../frontend/src/directives/adaptive-border.ts     |   2 +-
 packages/frontend/src/directives/hotkey.ts         |   2 +-
 packages/frontend/src/directives/panel.ts          |   2 +-
 packages/frontend/src/directives/tooltip.ts        |   2 +-
 packages/frontend/src/instance.ts                  |   2 +-
 packages/frontend/src/navbar.ts                    |   6 +-
 packages/frontend/src/os.ts                        |  14 +-
 packages/frontend/src/pages/_error_.vue            |   6 +-
 packages/frontend/src/pages/about-misskey.vue      |   6 +-
 packages/frontend/src/pages/about.overview.vue     |   2 +-
 packages/frontend/src/pages/about.vue              |   4 +-
 packages/frontend/src/pages/achievements.vue       |   4 +-
 packages/frontend/src/pages/admin-file.vue         |   4 +-
 packages/frontend/src/pages/admin-user.vue         |   4 +-
 .../src/pages/admin/RolesEditorFormula.vue         |   2 +-
 packages/frontend/src/pages/admin/_header_.vue     |   2 +-
 .../abuse-report/notification-recipient.editor.vue |   2 +-
 .../admin/abuse-report/notification-recipient.vue  |   2 +-
 packages/frontend/src/pages/admin/abuses.vue       |   2 +-
 packages/frontend/src/pages/admin/ads.vue          |   4 +-
 .../frontend/src/pages/admin/announcements.vue     |   4 +-
 .../frontend/src/pages/admin/bot-protection.vue    |   4 +-
 packages/frontend/src/pages/admin/branding.vue     |   4 +-
 .../admin/custom-emojis-manager.local.list.vue     |   4 +-
 .../admin/custom-emojis-manager.local.register.vue |  10 +-
 .../pages/admin/custom-emojis-manager.remote.vue   |   4 +-
 .../src/pages/admin/custom-emojis-manager2.vue     |   2 +-
 packages/frontend/src/pages/admin/database.vue     |   4 +-
 .../frontend/src/pages/admin/email-settings.vue    |   4 +-
 .../frontend/src/pages/admin/external-services.vue |   4 +-
 packages/frontend/src/pages/admin/federation.vue   |   2 +-
 packages/frontend/src/pages/admin/files.vue        |   4 +-
 packages/frontend/src/pages/admin/index.vue        |  10 +-
 packages/frontend/src/pages/admin/invites.vue      |   4 +-
 packages/frontend/src/pages/admin/moderation.vue   |   4 +-
 packages/frontend/src/pages/admin/modlog.vue       |   2 +-
 .../frontend/src/pages/admin/object-storage.vue    |   4 +-
 .../src/pages/admin/overview.active-users.vue      |   8 +-
 .../src/pages/admin/overview.ap-requests.vue       |  10 +-
 .../src/pages/admin/overview.federation.vue        |   4 +-
 .../src/pages/admin/overview.instances.vue         |   2 +-
 .../src/pages/admin/overview.moderators.vue        |   2 +-
 packages/frontend/src/pages/admin/overview.pie.vue |   4 +-
 .../src/pages/admin/overview.queue.chart.vue       |   8 +-
 .../frontend/src/pages/admin/overview.stats.vue    |   2 +-
 .../frontend/src/pages/admin/overview.users.vue    |   2 +-
 packages/frontend/src/pages/admin/overview.vue     |   4 +-
 packages/frontend/src/pages/admin/performance.vue  |   6 +-
 .../frontend/src/pages/admin/queue.chart.chart.vue |   8 +-
 packages/frontend/src/pages/admin/queue.chart.vue  |   2 +-
 packages/frontend/src/pages/admin/queue.vue        |   2 +-
 packages/frontend/src/pages/admin/relays.vue       |   4 +-
 packages/frontend/src/pages/admin/roles.edit.vue   |   4 +-
 packages/frontend/src/pages/admin/roles.editor.vue |   2 +-
 packages/frontend/src/pages/admin/roles.role.vue   |   4 +-
 packages/frontend/src/pages/admin/roles.vue        |   4 +-
 packages/frontend/src/pages/admin/security.vue     |   6 +-
 packages/frontend/src/pages/admin/server-rules.vue |   2 +-
 packages/frontend/src/pages/admin/settings.vue     |   6 +-
 .../frontend/src/pages/admin/system-webhook.vue    |   4 +-
 packages/frontend/src/pages/admin/users.vue        |   4 +-
 packages/frontend/src/pages/ads.vue                |   2 +-
 packages/frontend/src/pages/announcement.vue       |   4 +-
 packages/frontend/src/pages/announcements.vue      |   4 +-
 packages/frontend/src/pages/antenna-timeline.vue   |   4 +-
 packages/frontend/src/pages/api-console.vue        |   4 +-
 packages/frontend/src/pages/auth.form.vue          |   2 +-
 packages/frontend/src/pages/auth.vue               |   4 +-
 .../src/pages/avatar-decoration-edit-dialog.vue    |   2 +-
 packages/frontend/src/pages/avatar-decorations.vue |   4 +-
 packages/frontend/src/pages/channel-editor.vue     |   6 +-
 packages/frontend/src/pages/channel.vue            |  12 +-
 packages/frontend/src/pages/channels.vue           |   2 +-
 packages/frontend/src/pages/clicker.vue            |   2 +-
 packages/frontend/src/pages/clip.vue               |  10 +-
 packages/frontend/src/pages/contact.vue            |   2 +-
 .../frontend/src/pages/custom-emojis-manager.vue   |   8 +-
 packages/frontend/src/pages/drive.file.info.vue    |   2 +-
 packages/frontend/src/pages/drive.file.vue         |   2 +-
 packages/frontend/src/pages/drive.vue              |   2 +-
 .../frontend/src/pages/drop-and-fusion.game.vue    |  10 +-
 packages/frontend/src/pages/drop-and-fusion.vue    |   4 +-
 packages/frontend/src/pages/emoji-edit-dialog.vue  |   4 +-
 packages/frontend/src/pages/emojis.emoji.vue       |   4 +-
 packages/frontend/src/pages/explore.roles.vue      |   2 +-
 packages/frontend/src/pages/explore.users.vue      |   2 +-
 packages/frontend/src/pages/explore.vue            |   2 +-
 packages/frontend/src/pages/favorites.vue          |   2 +-
 packages/frontend/src/pages/flash/flash-edit.vue   |   4 +-
 packages/frontend/src/pages/flash/flash-index.vue  |   2 +-
 packages/frontend/src/pages/flash/flash.vue        |  16 +-
 packages/frontend/src/pages/follow-requests.vue    |   2 +-
 packages/frontend/src/pages/gallery/edit.vue       |   6 +-
 packages/frontend/src/pages/gallery/index.vue      |   2 +-
 packages/frontend/src/pages/gallery/post.vue       |   8 +-
 packages/frontend/src/pages/games.vue              |   2 +-
 packages/frontend/src/pages/install-extensions.vue |   8 +-
 packages/frontend/src/pages/instance-info.vue      |   6 +-
 packages/frontend/src/pages/invite.vue             |   4 +-
 packages/frontend/src/pages/list.vue               |   4 +-
 packages/frontend/src/pages/lookup.vue             |   4 +-
 packages/frontend/src/pages/miauth.vue             |   4 +-
 packages/frontend/src/pages/my-antennas/create.vue |   2 +-
 packages/frontend/src/pages/my-antennas/edit.vue   |   4 +-
 packages/frontend/src/pages/my-antennas/index.vue  |   2 +-
 packages/frontend/src/pages/my-clips/index.vue     |   4 +-
 packages/frontend/src/pages/my-lists/index.vue     |   2 +-
 packages/frontend/src/pages/my-lists/list.vue      |   4 +-
 packages/frontend/src/pages/not-found.vue          |   4 +-
 packages/frontend/src/pages/note.vue               |   8 +-
 packages/frontend/src/pages/notifications.vue      |   2 +-
 packages/frontend/src/pages/oauth.vue              |   2 +-
 .../pages/page-editor/els/page-editor.el.image.vue |   2 +-
 .../pages/page-editor/els/page-editor.el.note.vue  |   2 +-
 .../page-editor/els/page-editor.el.section.vue     |   4 +-
 .../pages/page-editor/els/page-editor.el.text.vue  |   4 +-
 .../frontend/src/pages/page-editor/page-editor.vue |   6 +-
 packages/frontend/src/pages/page.vue               |  12 +-
 packages/frontend/src/pages/pages.vue              |   2 +-
 packages/frontend/src/pages/preview.vue            |   2 +-
 packages/frontend/src/pages/registry.keys.vue      |   4 +-
 packages/frontend/src/pages/registry.value.vue     |   4 +-
 packages/frontend/src/pages/registry.vue           |   4 +-
 packages/frontend/src/pages/reset-password.vue     |   2 +-
 packages/frontend/src/pages/reversi/game.board.vue |   8 +-
 .../frontend/src/pages/reversi/game.setting.vue    |   2 +-
 packages/frontend/src/pages/reversi/game.vue       |   4 +-
 packages/frontend/src/pages/reversi/index.vue      |   8 +-
 packages/frontend/src/pages/role.vue               |   4 +-
 packages/frontend/src/pages/scratchpad.vue         |  12 +-
 packages/frontend/src/pages/search.note.vue        |   4 +-
 packages/frontend/src/pages/search.user.vue        |   2 +-
 packages/frontend/src/pages/search.vue             |   4 +-
 .../frontend/src/pages/settings/2fa.qrdialog.vue   |   2 +-
 .../frontend/src/pages/settings/accessibility.vue  |   4 +-
 packages/frontend/src/pages/settings/accounts.vue  |   4 +-
 packages/frontend/src/pages/settings/api.vue       |   4 +-
 .../frontend/src/pages/settings/appearance.vue     |   6 +-
 packages/frontend/src/pages/settings/apps.vue      |   4 +-
 .../src/pages/settings/avatar-decoration.vue       |   4 +-
 .../frontend/src/pages/settings/custom-css.vue     |   4 +-
 packages/frontend/src/pages/settings/deck.vue      |   2 +-
 .../frontend/src/pages/settings/drive-cleaner.vue  |   6 +-
 packages/frontend/src/pages/settings/drive.vue     |   4 +-
 packages/frontend/src/pages/settings/email.vue     |   4 +-
 .../frontend/src/pages/settings/emoji-picker.vue   |   8 +-
 .../frontend/src/pages/settings/import-export.vue  |   6 +-
 packages/frontend/src/pages/settings/index.vue     |   8 +-
 packages/frontend/src/pages/settings/migration.vue |   4 +-
 .../pages/settings/mute-block.instance-mute.vue    |   2 +-
 .../frontend/src/pages/settings/mute-block.vue     |   4 +-
 packages/frontend/src/pages/settings/navbar.vue    |   4 +-
 .../frontend/src/pages/settings/notifications.vue  |   4 +-
 packages/frontend/src/pages/settings/other.vue     |   6 +-
 .../frontend/src/pages/settings/plugin.install.vue |   4 +-
 packages/frontend/src/pages/settings/plugin.vue    |   6 +-
 .../frontend/src/pages/settings/preferences.vue    |   6 +-
 packages/frontend/src/pages/settings/privacy.vue   |   6 +-
 packages/frontend/src/pages/settings/profile.vue   |   8 +-
 packages/frontend/src/pages/settings/roles.vue     |   2 +-
 packages/frontend/src/pages/settings/security.vue  |   4 +-
 .../frontend/src/pages/settings/sounds.sound.vue   |   8 +-
 packages/frontend/src/pages/settings/sounds.vue    |   6 +-
 .../src/pages/settings/statusbar.statusbar.vue     |   2 +-
 packages/frontend/src/pages/settings/statusbar.vue |   4 +-
 .../frontend/src/pages/settings/theme.install.vue  |   4 +-
 .../frontend/src/pages/settings/theme.manage.vue   |   8 +-
 packages/frontend/src/pages/settings/theme.vue     |  12 +-
 .../frontend/src/pages/settings/webhook.edit.vue   |   4 +-
 .../frontend/src/pages/settings/webhook.new.vue    |   2 +-
 packages/frontend/src/pages/settings/webhook.vue   |   2 +-
 packages/frontend/src/pages/share.vue              |   6 +-
 packages/frontend/src/pages/signup-complete.vue    |   2 +-
 packages/frontend/src/pages/tag.vue                |   4 +-
 packages/frontend/src/pages/theme-editor.vue       |   8 +-
 packages/frontend/src/pages/timeline.vue           |   8 +-
 packages/frontend/src/pages/user-list-timeline.vue |   4 +-
 packages/frontend/src/pages/user-tag.vue           |   2 +-
 packages/frontend/src/pages/user/achievements.vue  |   2 +-
 .../frontend/src/pages/user/activity.following.vue |  10 +-
 .../frontend/src/pages/user/activity.notes.vue     |  10 +-
 packages/frontend/src/pages/user/activity.pv.vue   |  10 +-
 packages/frontend/src/pages/user/followers.vue     |   4 +-
 packages/frontend/src/pages/user/following.vue     |   4 +-
 packages/frontend/src/pages/user/home.vue          |  10 +-
 packages/frontend/src/pages/user/index.files.vue   |   2 +-
 packages/frontend/src/pages/user/index.vue         |   4 +-
 packages/frontend/src/pages/welcome.entrance.a.vue |   4 +-
 packages/frontend/src/pages/welcome.setup.vue      |   2 +-
 packages/frontend/src/pages/welcome.timeline.vue   |   2 +-
 packages/frontend/src/pages/welcome.vue            |   2 +-
 packages/frontend/src/pizzax.ts                    |   8 +-
 packages/frontend/src/plugin.ts                    |   4 +-
 packages/frontend/src/preferences/def.ts           |   6 +-
 packages/frontend/src/preferences/profile.ts       |   2 +-
 packages/frontend/src/preferences/utility.ts       |   6 +-
 packages/frontend/src/scripts/achievements.ts      | 513 -------------
 packages/frontend/src/scripts/admin-lookup.ts      |  88 ---
 packages/frontend/src/scripts/aiscript/api.ts      | 125 ----
 packages/frontend/src/scripts/aiscript/common.ts   |  16 -
 packages/frontend/src/scripts/aiscript/ui.ts       | 655 ----------------
 packages/frontend/src/scripts/array.ts             | 118 ---
 packages/frontend/src/scripts/autocomplete.ts      | 323 --------
 .../src/scripts/autogen/settings-search-index.ts   | 822 ---------------------
 packages/frontend/src/scripts/cache.ts             |  53 --
 packages/frontend/src/scripts/chart-legend.ts      |  17 -
 packages/frontend/src/scripts/chart-vline.ts       |  28 -
 packages/frontend/src/scripts/check-permissions.ts |  19 -
 .../src/scripts/check-reaction-permissions.ts      |  17 -
 packages/frontend/src/scripts/check-word-mute.ts   |  43 --
 packages/frontend/src/scripts/clear-cache.ts       |  24 -
 packages/frontend/src/scripts/clicker-game.ts      |  73 --
 packages/frontend/src/scripts/clone.ts             |  25 -
 packages/frontend/src/scripts/code-highlighter.ts  |  99 ---
 packages/frontend/src/scripts/collect-page-vars.ts |  73 --
 packages/frontend/src/scripts/color.ts             |  12 -
 packages/frontend/src/scripts/confetti.ts          |  30 -
 packages/frontend/src/scripts/contains.ts          |  14 -
 packages/frontend/src/scripts/copy-to-clipboard.ts |  11 -
 packages/frontend/src/scripts/device-kind.ts       |  24 -
 packages/frontend/src/scripts/emoji-picker.ts      |  61 --
 packages/frontend/src/scripts/extract-mentions.ts  |  16 -
 .../frontend/src/scripts/extract-url-from-mfm.ts   |  24 -
 packages/frontend/src/scripts/file-drop.ts         | 121 ---
 packages/frontend/src/scripts/focus-trap.ts        | 134 ----
 packages/frontend/src/scripts/focus.ts             |  80 --
 packages/frontend/src/scripts/form.ts              | 104 ---
 .../frontend/src/scripts/format-time-string.ts     |  55 --
 packages/frontend/src/scripts/fullscreen.ts        |  46 --
 .../frontend/src/scripts/get-account-from-id.ts    |  12 -
 packages/frontend/src/scripts/get-appear-note.ts   |  10 -
 packages/frontend/src/scripts/get-bg-color.ts      |  18 -
 .../frontend/src/scripts/get-dom-node-or-null.ts   |  19 -
 .../frontend/src/scripts/get-drive-file-menu.ts    | 162 ----
 packages/frontend/src/scripts/get-embed-code.ts    |  87 ---
 packages/frontend/src/scripts/get-note-menu.ts     | 685 -----------------
 packages/frontend/src/scripts/get-note-summary.ts  |  64 --
 packages/frontend/src/scripts/get-user-menu.ts     | 441 -----------
 packages/frontend/src/scripts/get-user-name.ts     |   8 -
 packages/frontend/src/scripts/hotkey.ts            | 172 -----
 packages/frontend/src/scripts/idb-proxy.ts         |  53 --
 packages/frontend/src/scripts/idle-render.ts       |  60 --
 packages/frontend/src/scripts/init-chart.ts        |  58 --
 packages/frontend/src/scripts/initialize-sw.ts     |  18 -
 packages/frontend/src/scripts/intl-const.ts        |  50 --
 packages/frontend/src/scripts/intl-string.ts       |  97 ---
 .../frontend/src/scripts/is-device-darkmode.ts     |   8 -
 packages/frontend/src/scripts/isFfVisibleForMe.ts  |  24 -
 packages/frontend/src/scripts/key-event.ts         | 153 ----
 packages/frontend/src/scripts/langmap.ts           | 671 -----------------
 packages/frontend/src/scripts/login-id.ts          |  16 -
 packages/frontend/src/scripts/lookup.ts            |  84 ---
 packages/frontend/src/scripts/media-has-audio.ts   |  14 -
 packages/frontend/src/scripts/media-proxy.ts       |  34 -
 packages/frontend/src/scripts/merge.ts             |  35 -
 .../frontend/src/scripts/mfm-function-picker.ts    |  57 --
 packages/frontend/src/scripts/misskey-api.ts       | 116 ---
 packages/frontend/src/scripts/navigator.ts         |   8 -
 packages/frontend/src/scripts/page-metadata.ts     |  71 --
 packages/frontend/src/scripts/physics.ts           | 157 ----
 .../frontend/src/scripts/player-url-transform.ts   |  26 -
 packages/frontend/src/scripts/please-login.ts      |  76 --
 packages/frontend/src/scripts/popout.ts            |  28 -
 packages/frontend/src/scripts/popup-position.ts    | 161 ----
 packages/frontend/src/scripts/post-message.ts      |  25 -
 packages/frontend/src/scripts/reaction-picker.ts   |  54 --
 packages/frontend/src/scripts/reload-ask.ts        |  40 -
 packages/frontend/src/scripts/search-emoji.ts      | 106 ---
 packages/frontend/src/scripts/select-file.ts       | 130 ----
 packages/frontend/src/scripts/show-moved-dialog.ts |  21 -
 .../frontend/src/scripts/show-suspended-dialog.ts  |  15 -
 packages/frontend/src/scripts/shuffle.ts           |  25 -
 packages/frontend/src/scripts/snowfall-effect.ts   | 490 ------------
 packages/frontend/src/scripts/sound.ts             | 258 -------
 packages/frontend/src/scripts/sticky-sidebar.ts    |  55 --
 packages/frontend/src/scripts/stream-mock.ts       |  81 --
 packages/frontend/src/scripts/test-utils.ts        |   9 -
 packages/frontend/src/scripts/theme-editor.ts      |  87 ---
 packages/frontend/src/scripts/theme.ts             | 189 -----
 packages/frontend/src/scripts/time.ts              |  45 --
 packages/frontend/src/scripts/timezones.ts         |  54 --
 packages/frontend/src/scripts/touch.ts             |  22 -
 packages/frontend/src/scripts/unison-reload.ts     |  20 -
 packages/frontend/src/scripts/upload.ts            | 162 ----
 .../frontend/src/scripts/upload/compress-config.ts |  36 -
 .../frontend/src/scripts/upload/isWebpSupported.ts |  15 -
 packages/frontend/src/scripts/use-chart-tooltip.ts |  63 --
 packages/frontend/src/scripts/use-form.ts          |  56 --
 packages/frontend/src/scripts/use-leave-guard.ts   |  50 --
 packages/frontend/src/scripts/use-note-capture.ts  | 124 ----
 packages/frontend/src/scripts/use-tooltip.ts       | 106 ---
 packages/frontend/src/store.ts                     |   4 +-
 packages/frontend/src/stream.ts                    |   2 +-
 packages/frontend/src/theme-store.ts               |   4 +-
 packages/frontend/src/ui/_common_/common.vue       |   6 +-
 packages/frontend/src/ui/_common_/navbar.vue       |   2 +-
 .../src/ui/_common_/statusbar-federation.vue       |   4 +-
 .../frontend/src/ui/_common_/statusbar-rss.vue     |   2 +-
 .../src/ui/_common_/statusbar-user-list.vue        |   4 +-
 packages/frontend/src/ui/_common_/sw-inject.ts     |   6 +-
 packages/frontend/src/ui/_common_/upload.vue       |   2 +-
 packages/frontend/src/ui/classic.sidebar.vue       |   2 +-
 packages/frontend/src/ui/classic.vue               |   6 +-
 packages/frontend/src/ui/deck.vue                  |   4 +-
 packages/frontend/src/ui/deck/antenna-column.vue   |   4 +-
 packages/frontend/src/ui/deck/channel-column.vue   |   4 +-
 packages/frontend/src/ui/deck/list-column.vue      |   4 +-
 packages/frontend/src/ui/deck/main-column.vue      |   4 +-
 .../frontend/src/ui/deck/role-timeline-column.vue  |   4 +-
 packages/frontend/src/ui/deck/tl-column.vue        |   2 +-
 .../frontend/src/ui/deck/tl-note-notification.ts   |   4 +-
 packages/frontend/src/ui/minimum.vue               |   4 +-
 packages/frontend/src/ui/universal.vue             |   6 +-
 packages/frontend/src/ui/visitor.vue               |   4 +-
 packages/frontend/src/ui/zen.vue                   |   4 +-
 packages/frontend/src/utility/achievements.ts      | 513 +++++++++++++
 packages/frontend/src/utility/admin-lookup.ts      |  88 +++
 packages/frontend/src/utility/array.ts             | 118 +++
 packages/frontend/src/utility/autocomplete.ts      | 323 ++++++++
 .../src/utility/autogen/settings-search-index.ts   | 822 +++++++++++++++++++++
 packages/frontend/src/utility/cache.ts             |  53 ++
 packages/frontend/src/utility/chart-legend.ts      |  17 +
 packages/frontend/src/utility/chart-vline.ts       |  28 +
 packages/frontend/src/utility/check-permissions.ts |  19 +
 .../src/utility/check-reaction-permissions.ts      |  17 +
 packages/frontend/src/utility/check-word-mute.ts   |  43 ++
 packages/frontend/src/utility/clear-cache.ts       |  24 +
 packages/frontend/src/utility/clicker-game.ts      |  73 ++
 packages/frontend/src/utility/clone.ts             |  25 +
 packages/frontend/src/utility/code-highlighter.ts  |  99 +++
 packages/frontend/src/utility/collect-page-vars.ts |  73 ++
 packages/frontend/src/utility/color.ts             |  12 +
 packages/frontend/src/utility/confetti.ts          |  30 +
 packages/frontend/src/utility/contains.ts          |  14 +
 packages/frontend/src/utility/copy-to-clipboard.ts |  11 +
 packages/frontend/src/utility/device-kind.ts       |  24 +
 packages/frontend/src/utility/emoji-picker.ts      |  61 ++
 packages/frontend/src/utility/extract-mentions.ts  |  16 +
 .../frontend/src/utility/extract-url-from-mfm.ts   |  24 +
 packages/frontend/src/utility/file-drop.ts         | 121 +++
 packages/frontend/src/utility/focus-trap.ts        | 134 ++++
 packages/frontend/src/utility/focus.ts             |  80 ++
 packages/frontend/src/utility/form.ts              | 104 +++
 .../frontend/src/utility/format-time-string.ts     |  55 ++
 packages/frontend/src/utility/fullscreen.ts        |  46 ++
 .../frontend/src/utility/get-account-from-id.ts    |  12 +
 packages/frontend/src/utility/get-appear-note.ts   |  10 +
 packages/frontend/src/utility/get-bg-color.ts      |  18 +
 .../frontend/src/utility/get-dom-node-or-null.ts   |  19 +
 .../frontend/src/utility/get-drive-file-menu.ts    | 162 ++++
 packages/frontend/src/utility/get-embed-code.ts    |  87 +++
 packages/frontend/src/utility/get-note-menu.ts     | 685 +++++++++++++++++
 packages/frontend/src/utility/get-note-summary.ts  |  64 ++
 packages/frontend/src/utility/get-user-menu.ts     | 441 +++++++++++
 packages/frontend/src/utility/get-user-name.ts     |   8 +
 packages/frontend/src/utility/hotkey.ts            | 172 +++++
 packages/frontend/src/utility/idb-proxy.ts         |  53 ++
 packages/frontend/src/utility/idle-render.ts       |  60 ++
 packages/frontend/src/utility/init-chart.ts        |  58 ++
 packages/frontend/src/utility/initialize-sw.ts     |  18 +
 packages/frontend/src/utility/intl-const.ts        |  50 ++
 packages/frontend/src/utility/intl-string.ts       |  97 +++
 .../frontend/src/utility/is-device-darkmode.ts     |   8 +
 packages/frontend/src/utility/isFfVisibleForMe.ts  |  24 +
 packages/frontend/src/utility/key-event.ts         | 153 ++++
 packages/frontend/src/utility/langmap.ts           | 671 +++++++++++++++++
 packages/frontend/src/utility/login-id.ts          |  16 +
 packages/frontend/src/utility/lookup.ts            |  84 +++
 packages/frontend/src/utility/media-has-audio.ts   |  14 +
 packages/frontend/src/utility/media-proxy.ts       |  34 +
 packages/frontend/src/utility/merge.ts             |  35 +
 .../frontend/src/utility/mfm-function-picker.ts    |  57 ++
 packages/frontend/src/utility/misskey-api.ts       | 116 +++
 packages/frontend/src/utility/navigator.ts         |   8 +
 packages/frontend/src/utility/page-metadata.ts     |  71 ++
 packages/frontend/src/utility/physics.ts           | 157 ++++
 .../frontend/src/utility/player-url-transform.ts   |  26 +
 packages/frontend/src/utility/please-login.ts      |  76 ++
 packages/frontend/src/utility/popout.ts            |  28 +
 packages/frontend/src/utility/popup-position.ts    | 161 ++++
 packages/frontend/src/utility/post-message.ts      |  25 +
 packages/frontend/src/utility/reaction-picker.ts   |  54 ++
 packages/frontend/src/utility/reload-ask.ts        |  40 +
 packages/frontend/src/utility/search-emoji.ts      | 106 +++
 packages/frontend/src/utility/select-file.ts       | 130 ++++
 packages/frontend/src/utility/show-moved-dialog.ts |  21 +
 .../frontend/src/utility/show-suspended-dialog.ts  |  15 +
 packages/frontend/src/utility/shuffle.ts           |  25 +
 packages/frontend/src/utility/snowfall-effect.ts   | 490 ++++++++++++
 packages/frontend/src/utility/sound.ts             | 258 +++++++
 packages/frontend/src/utility/sticky-sidebar.ts    |  55 ++
 packages/frontend/src/utility/stream-mock.ts       |  81 ++
 packages/frontend/src/utility/test-utils.ts        |   9 +
 packages/frontend/src/utility/theme-editor.ts      |  87 +++
 packages/frontend/src/utility/theme.ts             | 189 +++++
 packages/frontend/src/utility/time.ts              |  45 ++
 packages/frontend/src/utility/timezones.ts         |  54 ++
 packages/frontend/src/utility/touch.ts             |  22 +
 packages/frontend/src/utility/unison-reload.ts     |  20 +
 packages/frontend/src/utility/upload.ts            | 162 ++++
 .../frontend/src/utility/upload/compress-config.ts |  36 +
 .../frontend/src/utility/upload/isWebpSupported.ts |  15 +
 packages/frontend/src/utility/use-chart-tooltip.ts |  63 ++
 packages/frontend/src/utility/use-form.ts          |  56 ++
 packages/frontend/src/utility/use-leave-guard.ts   |  50 ++
 packages/frontend/src/utility/use-note-capture.ts  | 124 ++++
 packages/frontend/src/utility/use-tooltip.ts       | 106 +++
 packages/frontend/src/widgets/WidgetActivity.vue   |   4 +-
 packages/frontend/src/widgets/WidgetAichan.vue     |   2 +-
 packages/frontend/src/widgets/WidgetAiscript.vue   |   4 +-
 .../frontend/src/widgets/WidgetAiscriptApp.vue     |   8 +-
 .../src/widgets/WidgetBirthdayFollowings.vue       |   4 +-
 packages/frontend/src/widgets/WidgetButton.vue     |   4 +-
 packages/frontend/src/widgets/WidgetCalendar.vue   |   2 +-
 packages/frontend/src/widgets/WidgetClicker.vue    |   2 +-
 packages/frontend/src/widgets/WidgetClock.vue      |   4 +-
 .../frontend/src/widgets/WidgetDigitalClock.vue    |   4 +-
 packages/frontend/src/widgets/WidgetFederation.vue |   6 +-
 .../frontend/src/widgets/WidgetInstanceCloud.vue   |   6 +-
 .../frontend/src/widgets/WidgetInstanceInfo.vue    |   2 +-
 packages/frontend/src/widgets/WidgetJobQueue.vue   |   6 +-
 packages/frontend/src/widgets/WidgetMemo.vue       |   2 +-
 .../frontend/src/widgets/WidgetNotifications.vue   |   2 +-
 .../frontend/src/widgets/WidgetOnlineUsers.vue     |   4 +-
 packages/frontend/src/widgets/WidgetPhotos.vue     |   6 +-
 packages/frontend/src/widgets/WidgetPostForm.vue   |   2 +-
 packages/frontend/src/widgets/WidgetProfile.vue    |   2 +-
 packages/frontend/src/widgets/WidgetRss.vue        |   2 +-
 packages/frontend/src/widgets/WidgetRssTicker.vue  |   4 +-
 packages/frontend/src/widgets/WidgetSlideshow.vue  |   4 +-
 packages/frontend/src/widgets/WidgetTimeline.vue   |   4 +-
 packages/frontend/src/widgets/WidgetTrends.vue     |   4 +-
 packages/frontend/src/widgets/WidgetUnixClock.vue  |   2 +-
 packages/frontend/src/widgets/WidgetUserList.vue   |   4 +-
 .../frontend/src/widgets/server-metric/index.vue   |   4 +-
 packages/frontend/src/widgets/widget.ts            |   4 +-
 packages/frontend/test/aiscript/api.test.ts        |   4 +-
 packages/frontend/test/aiscript/common.test.ts     |   2 +-
 packages/frontend/test/aiscript/ui.test.ts         |   4 +-
 packages/frontend/test/autocomplete.test.ts        |   2 +-
 packages/frontend/test/intl-string.test.ts         |   2 +-
 packages/frontend/vite.config.ts                   |   2 +-
 559 files changed, 10755 insertions(+), 10755 deletions(-)
 create mode 100644 packages/frontend/src/aiscript/api.ts
 create mode 100644 packages/frontend/src/aiscript/common.ts
 create mode 100644 packages/frontend/src/aiscript/ui.ts
 delete mode 100644 packages/frontend/src/scripts/achievements.ts
 delete mode 100644 packages/frontend/src/scripts/admin-lookup.ts
 delete mode 100644 packages/frontend/src/scripts/aiscript/api.ts
 delete mode 100644 packages/frontend/src/scripts/aiscript/common.ts
 delete mode 100644 packages/frontend/src/scripts/aiscript/ui.ts
 delete mode 100644 packages/frontend/src/scripts/array.ts
 delete mode 100644 packages/frontend/src/scripts/autocomplete.ts
 delete mode 100644 packages/frontend/src/scripts/autogen/settings-search-index.ts
 delete mode 100644 packages/frontend/src/scripts/cache.ts
 delete mode 100644 packages/frontend/src/scripts/chart-legend.ts
 delete mode 100644 packages/frontend/src/scripts/chart-vline.ts
 delete mode 100644 packages/frontend/src/scripts/check-permissions.ts
 delete mode 100644 packages/frontend/src/scripts/check-reaction-permissions.ts
 delete mode 100644 packages/frontend/src/scripts/check-word-mute.ts
 delete mode 100644 packages/frontend/src/scripts/clear-cache.ts
 delete mode 100644 packages/frontend/src/scripts/clicker-game.ts
 delete mode 100644 packages/frontend/src/scripts/clone.ts
 delete mode 100644 packages/frontend/src/scripts/code-highlighter.ts
 delete mode 100644 packages/frontend/src/scripts/collect-page-vars.ts
 delete mode 100644 packages/frontend/src/scripts/color.ts
 delete mode 100644 packages/frontend/src/scripts/confetti.ts
 delete mode 100644 packages/frontend/src/scripts/contains.ts
 delete mode 100644 packages/frontend/src/scripts/copy-to-clipboard.ts
 delete mode 100644 packages/frontend/src/scripts/device-kind.ts
 delete mode 100644 packages/frontend/src/scripts/emoji-picker.ts
 delete mode 100644 packages/frontend/src/scripts/extract-mentions.ts
 delete mode 100644 packages/frontend/src/scripts/extract-url-from-mfm.ts
 delete mode 100644 packages/frontend/src/scripts/file-drop.ts
 delete mode 100644 packages/frontend/src/scripts/focus-trap.ts
 delete mode 100644 packages/frontend/src/scripts/focus.ts
 delete mode 100644 packages/frontend/src/scripts/form.ts
 delete mode 100644 packages/frontend/src/scripts/format-time-string.ts
 delete mode 100644 packages/frontend/src/scripts/fullscreen.ts
 delete mode 100644 packages/frontend/src/scripts/get-account-from-id.ts
 delete mode 100644 packages/frontend/src/scripts/get-appear-note.ts
 delete mode 100644 packages/frontend/src/scripts/get-bg-color.ts
 delete mode 100644 packages/frontend/src/scripts/get-dom-node-or-null.ts
 delete mode 100644 packages/frontend/src/scripts/get-drive-file-menu.ts
 delete mode 100644 packages/frontend/src/scripts/get-embed-code.ts
 delete mode 100644 packages/frontend/src/scripts/get-note-menu.ts
 delete mode 100644 packages/frontend/src/scripts/get-note-summary.ts
 delete mode 100644 packages/frontend/src/scripts/get-user-menu.ts
 delete mode 100644 packages/frontend/src/scripts/get-user-name.ts
 delete mode 100644 packages/frontend/src/scripts/hotkey.ts
 delete mode 100644 packages/frontend/src/scripts/idb-proxy.ts
 delete mode 100644 packages/frontend/src/scripts/idle-render.ts
 delete mode 100644 packages/frontend/src/scripts/init-chart.ts
 delete mode 100644 packages/frontend/src/scripts/initialize-sw.ts
 delete mode 100644 packages/frontend/src/scripts/intl-const.ts
 delete mode 100644 packages/frontend/src/scripts/intl-string.ts
 delete mode 100644 packages/frontend/src/scripts/is-device-darkmode.ts
 delete mode 100644 packages/frontend/src/scripts/isFfVisibleForMe.ts
 delete mode 100644 packages/frontend/src/scripts/key-event.ts
 delete mode 100644 packages/frontend/src/scripts/langmap.ts
 delete mode 100644 packages/frontend/src/scripts/login-id.ts
 delete mode 100644 packages/frontend/src/scripts/lookup.ts
 delete mode 100644 packages/frontend/src/scripts/media-has-audio.ts
 delete mode 100644 packages/frontend/src/scripts/media-proxy.ts
 delete mode 100644 packages/frontend/src/scripts/merge.ts
 delete mode 100644 packages/frontend/src/scripts/mfm-function-picker.ts
 delete mode 100644 packages/frontend/src/scripts/misskey-api.ts
 delete mode 100644 packages/frontend/src/scripts/navigator.ts
 delete mode 100644 packages/frontend/src/scripts/page-metadata.ts
 delete mode 100644 packages/frontend/src/scripts/physics.ts
 delete mode 100644 packages/frontend/src/scripts/player-url-transform.ts
 delete mode 100644 packages/frontend/src/scripts/please-login.ts
 delete mode 100644 packages/frontend/src/scripts/popout.ts
 delete mode 100644 packages/frontend/src/scripts/popup-position.ts
 delete mode 100644 packages/frontend/src/scripts/post-message.ts
 delete mode 100644 packages/frontend/src/scripts/reaction-picker.ts
 delete mode 100644 packages/frontend/src/scripts/reload-ask.ts
 delete mode 100644 packages/frontend/src/scripts/search-emoji.ts
 delete mode 100644 packages/frontend/src/scripts/select-file.ts
 delete mode 100644 packages/frontend/src/scripts/show-moved-dialog.ts
 delete mode 100644 packages/frontend/src/scripts/show-suspended-dialog.ts
 delete mode 100644 packages/frontend/src/scripts/shuffle.ts
 delete mode 100644 packages/frontend/src/scripts/snowfall-effect.ts
 delete mode 100644 packages/frontend/src/scripts/sound.ts
 delete mode 100644 packages/frontend/src/scripts/sticky-sidebar.ts
 delete mode 100644 packages/frontend/src/scripts/stream-mock.ts
 delete mode 100644 packages/frontend/src/scripts/test-utils.ts
 delete mode 100644 packages/frontend/src/scripts/theme-editor.ts
 delete mode 100644 packages/frontend/src/scripts/theme.ts
 delete mode 100644 packages/frontend/src/scripts/time.ts
 delete mode 100644 packages/frontend/src/scripts/timezones.ts
 delete mode 100644 packages/frontend/src/scripts/touch.ts
 delete mode 100644 packages/frontend/src/scripts/unison-reload.ts
 delete mode 100644 packages/frontend/src/scripts/upload.ts
 delete mode 100644 packages/frontend/src/scripts/upload/compress-config.ts
 delete mode 100644 packages/frontend/src/scripts/upload/isWebpSupported.ts
 delete mode 100644 packages/frontend/src/scripts/use-chart-tooltip.ts
 delete mode 100644 packages/frontend/src/scripts/use-form.ts
 delete mode 100644 packages/frontend/src/scripts/use-leave-guard.ts
 delete mode 100644 packages/frontend/src/scripts/use-note-capture.ts
 delete mode 100644 packages/frontend/src/scripts/use-tooltip.ts
 create mode 100644 packages/frontend/src/utility/achievements.ts
 create mode 100644 packages/frontend/src/utility/admin-lookup.ts
 create mode 100644 packages/frontend/src/utility/array.ts
 create mode 100644 packages/frontend/src/utility/autocomplete.ts
 create mode 100644 packages/frontend/src/utility/autogen/settings-search-index.ts
 create mode 100644 packages/frontend/src/utility/cache.ts
 create mode 100644 packages/frontend/src/utility/chart-legend.ts
 create mode 100644 packages/frontend/src/utility/chart-vline.ts
 create mode 100644 packages/frontend/src/utility/check-permissions.ts
 create mode 100644 packages/frontend/src/utility/check-reaction-permissions.ts
 create mode 100644 packages/frontend/src/utility/check-word-mute.ts
 create mode 100644 packages/frontend/src/utility/clear-cache.ts
 create mode 100644 packages/frontend/src/utility/clicker-game.ts
 create mode 100644 packages/frontend/src/utility/clone.ts
 create mode 100644 packages/frontend/src/utility/code-highlighter.ts
 create mode 100644 packages/frontend/src/utility/collect-page-vars.ts
 create mode 100644 packages/frontend/src/utility/color.ts
 create mode 100644 packages/frontend/src/utility/confetti.ts
 create mode 100644 packages/frontend/src/utility/contains.ts
 create mode 100644 packages/frontend/src/utility/copy-to-clipboard.ts
 create mode 100644 packages/frontend/src/utility/device-kind.ts
 create mode 100644 packages/frontend/src/utility/emoji-picker.ts
 create mode 100644 packages/frontend/src/utility/extract-mentions.ts
 create mode 100644 packages/frontend/src/utility/extract-url-from-mfm.ts
 create mode 100644 packages/frontend/src/utility/file-drop.ts
 create mode 100644 packages/frontend/src/utility/focus-trap.ts
 create mode 100644 packages/frontend/src/utility/focus.ts
 create mode 100644 packages/frontend/src/utility/form.ts
 create mode 100644 packages/frontend/src/utility/format-time-string.ts
 create mode 100644 packages/frontend/src/utility/fullscreen.ts
 create mode 100644 packages/frontend/src/utility/get-account-from-id.ts
 create mode 100644 packages/frontend/src/utility/get-appear-note.ts
 create mode 100644 packages/frontend/src/utility/get-bg-color.ts
 create mode 100644 packages/frontend/src/utility/get-dom-node-or-null.ts
 create mode 100644 packages/frontend/src/utility/get-drive-file-menu.ts
 create mode 100644 packages/frontend/src/utility/get-embed-code.ts
 create mode 100644 packages/frontend/src/utility/get-note-menu.ts
 create mode 100644 packages/frontend/src/utility/get-note-summary.ts
 create mode 100644 packages/frontend/src/utility/get-user-menu.ts
 create mode 100644 packages/frontend/src/utility/get-user-name.ts
 create mode 100644 packages/frontend/src/utility/hotkey.ts
 create mode 100644 packages/frontend/src/utility/idb-proxy.ts
 create mode 100644 packages/frontend/src/utility/idle-render.ts
 create mode 100644 packages/frontend/src/utility/init-chart.ts
 create mode 100644 packages/frontend/src/utility/initialize-sw.ts
 create mode 100644 packages/frontend/src/utility/intl-const.ts
 create mode 100644 packages/frontend/src/utility/intl-string.ts
 create mode 100644 packages/frontend/src/utility/is-device-darkmode.ts
 create mode 100644 packages/frontend/src/utility/isFfVisibleForMe.ts
 create mode 100644 packages/frontend/src/utility/key-event.ts
 create mode 100644 packages/frontend/src/utility/langmap.ts
 create mode 100644 packages/frontend/src/utility/login-id.ts
 create mode 100644 packages/frontend/src/utility/lookup.ts
 create mode 100644 packages/frontend/src/utility/media-has-audio.ts
 create mode 100644 packages/frontend/src/utility/media-proxy.ts
 create mode 100644 packages/frontend/src/utility/merge.ts
 create mode 100644 packages/frontend/src/utility/mfm-function-picker.ts
 create mode 100644 packages/frontend/src/utility/misskey-api.ts
 create mode 100644 packages/frontend/src/utility/navigator.ts
 create mode 100644 packages/frontend/src/utility/page-metadata.ts
 create mode 100644 packages/frontend/src/utility/physics.ts
 create mode 100644 packages/frontend/src/utility/player-url-transform.ts
 create mode 100644 packages/frontend/src/utility/please-login.ts
 create mode 100644 packages/frontend/src/utility/popout.ts
 create mode 100644 packages/frontend/src/utility/popup-position.ts
 create mode 100644 packages/frontend/src/utility/post-message.ts
 create mode 100644 packages/frontend/src/utility/reaction-picker.ts
 create mode 100644 packages/frontend/src/utility/reload-ask.ts
 create mode 100644 packages/frontend/src/utility/search-emoji.ts
 create mode 100644 packages/frontend/src/utility/select-file.ts
 create mode 100644 packages/frontend/src/utility/show-moved-dialog.ts
 create mode 100644 packages/frontend/src/utility/show-suspended-dialog.ts
 create mode 100644 packages/frontend/src/utility/shuffle.ts
 create mode 100644 packages/frontend/src/utility/snowfall-effect.ts
 create mode 100644 packages/frontend/src/utility/sound.ts
 create mode 100644 packages/frontend/src/utility/sticky-sidebar.ts
 create mode 100644 packages/frontend/src/utility/stream-mock.ts
 create mode 100644 packages/frontend/src/utility/test-utils.ts
 create mode 100644 packages/frontend/src/utility/theme-editor.ts
 create mode 100644 packages/frontend/src/utility/theme.ts
 create mode 100644 packages/frontend/src/utility/time.ts
 create mode 100644 packages/frontend/src/utility/timezones.ts
 create mode 100644 packages/frontend/src/utility/touch.ts
 create mode 100644 packages/frontend/src/utility/unison-reload.ts
 create mode 100644 packages/frontend/src/utility/upload.ts
 create mode 100644 packages/frontend/src/utility/upload/compress-config.ts
 create mode 100644 packages/frontend/src/utility/upload/isWebpSupported.ts
 create mode 100644 packages/frontend/src/utility/use-chart-tooltip.ts
 create mode 100644 packages/frontend/src/utility/use-form.ts
 create mode 100644 packages/frontend/src/utility/use-leave-guard.ts
 create mode 100644 packages/frontend/src/utility/use-note-capture.ts
 create mode 100644 packages/frontend/src/utility/use-tooltip.ts

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index 00639be642..938e4a623f 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -21,7 +21,7 @@ let moduleInitialized = false;
 let unobserve = () => {};
 let misskeyOS = null;
 
-function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
+function loadTheme(applyTheme: typeof import('../src/utility/theme')['applyTheme']) {
 	unobserve();
 	const theme = themes[document.documentElement.dataset.misskeyTheme];
 	if (theme) {
@@ -67,7 +67,7 @@ queueMicrotask(() => {
 		import('../src/components'),
 		import('../src/directives'),
 		import('../src/widgets'),
-		import('../src/scripts/theme'),
+		import('../src/utility/theme'),
 		import('../src/preferences'),
 		import('../src/os'),
 	]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => {
diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts
index 70afc356c1..473e386be9 100644
--- a/packages/frontend/@types/theme.d.ts
+++ b/packages/frontend/@types/theme.d.ts
@@ -4,7 +4,7 @@
  */
 
 declare module '@@/themes/*.json5' {
-	import { Theme } from '@/scripts/theme.js';
+	import { Theme } from '@/utility/theme.js';
 
 	const theme: Theme;
 
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 17d690cd3a..5e2a1f45ac 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -8,13 +8,13 @@ import * as Misskey from 'misskey-js';
 import { apiUrl } from '@@/js/config.js';
 import type { MenuItem, MenuButton } from '@/types/menu.js';
 import { defaultMemoryStorage } from '@/memory-storage';
-import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
+import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { del, get, set } from '@/scripts/idb-proxy.js';
+import { del, get, set } from '@/utility/idb-proxy.js';
 import { waiting, popup, popupMenu, success, alert } from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
 
 // TODO: 他のタブと永続化されたstateを同期
 
diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts
new file mode 100644
index 0000000000..3acc1127c9
--- /dev/null
+++ b/packages/frontend/src/aiscript/api.ts
@@ -0,0 +1,125 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { errors, utils, values } from '@syuilo/aiscript';
+import * as Misskey from 'misskey-js';
+import { url, lang } from '@@/js/config.js';
+import { assertStringAndIsIn } from './common.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { $i } from '@/account.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { customEmojis } from '@/custom-emojis.js';
+
+const DIALOG_TYPES = [
+	'error',
+	'info',
+	'success',
+	'warning',
+	'waiting',
+	'question',
+] as const;
+
+export function aiScriptReadline(q: string): Promise<string> {
+	return new Promise(ok => {
+		os.inputText({
+			title: q,
+		}).then(({ result: a }) => {
+			ok(a ?? '');
+		});
+	});
+}
+
+export function createAiScriptEnv(opts: { storageKey: string, token?: string }) {
+	return {
+		USER_ID: $i ? values.STR($i.id) : values.NULL,
+		USER_NAME: $i?.name ? values.STR($i.name) : values.NULL,
+		USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
+		CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
+		LOCALE: values.STR(lang),
+		SERVER_URL: values.STR(url),
+		'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
+			utils.assertString(title);
+			utils.assertString(text);
+			if (type != null) {
+				assertStringAndIsIn(type, DIALOG_TYPES);
+			}
+			await os.alert({
+				type: type ? type.value : 'info',
+				title: title.value,
+				text: text.value,
+			});
+			return values.NULL;
+		}),
+		'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
+			utils.assertString(title);
+			utils.assertString(text);
+			if (type != null) {
+				assertStringAndIsIn(type, DIALOG_TYPES);
+			}
+			const confirm = await os.confirm({
+				type: type ? type.value : 'question',
+				title: title.value,
+				text: text.value,
+			});
+			return confirm.canceled ? values.FALSE : values.TRUE;
+		}),
+		'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
+			utils.assertString(ep);
+			if (ep.value.includes('://')) {
+				throw new errors.AiScriptRuntimeError('invalid endpoint');
+			}
+			if (token) {
+				utils.assertString(token);
+				// バグがあればundefinedもあり得るため念のため
+				if (typeof token.value !== 'string') throw new Error('invalid token');
+			}
+			const actualToken: string | null = token?.value ?? opts.token ?? null;
+			if (param == null) {
+				throw new errors.AiScriptRuntimeError('expected param');
+			}
+			utils.assertObject(param);
+			return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
+				return utils.jsToVal(res);
+			}, err => {
+				return values.ERROR('request_failed', utils.jsToVal(err));
+			});
+		}),
+		/* セキュリティ上の問題があるため無効化
+		'Mk:apiExternal': values.FN_NATIVE(async ([host, ep, param, token]) => {
+			utils.assertString(host);
+			utils.assertString(ep);
+			if (token) utils.assertString(token);
+			return os.apiExternal(host.value, ep.value, utils.valToJs(param), token?.value).then(res => {
+				return utils.jsToVal(res);
+			}, err => {
+				return values.ERROR('request_failed', utils.jsToVal(err));
+			});
+		}),
+		*/
+		'Mk:save': values.FN_NATIVE(([key, value]) => {
+			utils.assertString(key);
+			utils.expectAny(value);
+			miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value)));
+			return values.NULL;
+		}),
+		'Mk:load': values.FN_NATIVE(([key]) => {
+			utils.assertString(key);
+			return utils.jsToVal(miLocalStorage.getItemAsJson(`aiscript:${opts.storageKey}:${key.value}`) ?? null);
+		}),
+		'Mk:remove': values.FN_NATIVE(([key]) => {
+			utils.assertString(key);
+			miLocalStorage.removeItem(`aiscript:${opts.storageKey}:${key.value}`);
+			return values.NULL;
+		}),
+		'Mk:url': values.FN_NATIVE(() => {
+			return values.STR(window.location.href);
+		}),
+		'Mk:nyaize': values.FN_NATIVE(([text]) => {
+			utils.assertString(text);
+			return values.STR(Misskey.nyaize(text.value));
+		}),
+	};
+}
diff --git a/packages/frontend/src/aiscript/common.ts b/packages/frontend/src/aiscript/common.ts
new file mode 100644
index 0000000000..ba5dfb8368
--- /dev/null
+++ b/packages/frontend/src/aiscript/common.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { errors, utils } from '@syuilo/aiscript';
+import type { values } from '@syuilo/aiscript';
+
+export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } {
+	utils.assertString(value);
+	const str = value.value;
+	if (!expects.includes(str)) {
+		const expected = expects.map((expect) => `"${expect}"`).join(', ');
+		throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`);
+	}
+}
diff --git a/packages/frontend/src/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts
new file mode 100644
index 0000000000..46e193f7c1
--- /dev/null
+++ b/packages/frontend/src/aiscript/ui.ts
@@ -0,0 +1,655 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { utils, values } from '@syuilo/aiscript';
+import { v4 as uuid } from 'uuid';
+import { ref } from 'vue';
+import type { Ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { assertStringAndIsIn } from './common.js';
+
+const ALIGNS = ['left', 'center', 'right'] as const;
+const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
+const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const;
+
+type Align = (typeof ALIGNS)[number];
+type Font = (typeof FONTS)[number];
+type BorderStyle = (typeof BORDER_STYLES)[number];
+
+export type AsUiComponentBase = {
+	id: string;
+	hidden?: boolean;
+};
+
+export type AsUiRoot = AsUiComponentBase & {
+	type: 'root';
+	children: AsUiComponent['id'][];
+};
+
+export type AsUiContainer = AsUiComponentBase & {
+	type: 'container';
+	children?: AsUiComponent['id'][];
+	align?: Align;
+	bgColor?: string;
+	fgColor?: string;
+	font?: Font;
+	borderWidth?: number;
+	borderColor?: string;
+	borderStyle?: BorderStyle;
+	borderRadius?: number;
+	padding?: number;
+	rounded?: boolean;
+	hidden?: boolean;
+};
+
+export type AsUiText = AsUiComponentBase & {
+	type: 'text';
+	text?: string;
+	size?: number;
+	bold?: boolean;
+	color?: string;
+	font?: Font;
+};
+
+export type AsUiMfm = AsUiComponentBase & {
+	type: 'mfm';
+	text?: string;
+	size?: number;
+	bold?: boolean;
+	color?: string;
+	font?: Font;
+	onClickEv?: (evId: string) => Promise<void>;
+};
+
+export type AsUiButton = AsUiComponentBase & {
+	type: 'button';
+	text?: string;
+	onClick?: () => Promise<void>;
+	primary?: boolean;
+	rounded?: boolean;
+	disabled?: boolean;
+};
+
+export type AsUiButtons = AsUiComponentBase & {
+	type: 'buttons';
+	buttons?: AsUiButton[];
+};
+
+export type AsUiSwitch = AsUiComponentBase & {
+	type: 'switch';
+	onChange?: (v: boolean) => Promise<void>;
+	default?: boolean;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiTextarea = AsUiComponentBase & {
+	type: 'textarea';
+	onInput?: (v: string) => Promise<void>;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiTextInput = AsUiComponentBase & {
+	type: 'textInput';
+	onInput?: (v: string) => Promise<void>;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiNumberInput = AsUiComponentBase & {
+	type: 'numberInput';
+	onInput?: (v: number) => Promise<void>;
+	default?: number;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiSelect = AsUiComponentBase & {
+	type: 'select';
+	items?: {
+		text: string;
+		value: string;
+	}[];
+	onChange?: (v: string) => Promise<void>;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiFolder = AsUiComponentBase & {
+	type: 'folder';
+	children?: AsUiComponent['id'][];
+	title?: string;
+	opened?: boolean;
+};
+
+type PostFormPropsForAsUi = {
+	text: string;
+	cw?: string;
+	visibility?: (typeof Misskey.noteVisibilities)[number];
+	localOnly?: boolean;
+};
+
+export type AsUiPostFormButton = AsUiComponentBase & {
+	type: 'postFormButton';
+	text?: string;
+	primary?: boolean;
+	rounded?: boolean;
+	form?: PostFormPropsForAsUi;
+};
+
+export type AsUiPostForm = AsUiComponentBase & {
+	type: 'postForm';
+	form?: PostFormPropsForAsUi;
+};
+
+export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm;
+
+type Options<T extends AsUiComponent> = T extends AsUiButtons
+	? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] }
+	: Omit<T, 'id' | 'type'>;
+
+export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
+	// TODO
+}
+
+function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> {
+	utils.assertObject(def);
+
+	const children = def.value.get('children');
+	utils.assertArray(children);
+
+	return {
+		children: children.value.map(v => {
+			utils.assertObject(v);
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
+		}),
+	};
+}
+
+function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> {
+	utils.assertObject(def);
+
+	const children = def.value.get('children');
+	if (children) utils.assertArray(children);
+	const align = def.value.get('align');
+	if (align) assertStringAndIsIn(align, ALIGNS);
+	const bgColor = def.value.get('bgColor');
+	if (bgColor) utils.assertString(bgColor);
+	const fgColor = def.value.get('fgColor');
+	if (fgColor) utils.assertString(fgColor);
+	const font = def.value.get('font');
+	if (font) assertStringAndIsIn(font, FONTS);
+	const borderWidth = def.value.get('borderWidth');
+	if (borderWidth) utils.assertNumber(borderWidth);
+	const borderColor = def.value.get('borderColor');
+	if (borderColor) utils.assertString(borderColor);
+	const borderStyle = def.value.get('borderStyle');
+	if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES);
+	const borderRadius = def.value.get('borderRadius');
+	if (borderRadius) utils.assertNumber(borderRadius);
+	const padding = def.value.get('padding');
+	if (padding) utils.assertNumber(padding);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+	const hidden = def.value.get('hidden');
+	if (hidden) utils.assertBoolean(hidden);
+
+	return {
+		children: children ? children.value.map(v => {
+			utils.assertObject(v);
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
+		}) : [],
+		align: align?.value,
+		fgColor: fgColor?.value,
+		bgColor: bgColor?.value,
+		font: font?.value,
+		borderWidth: borderWidth?.value,
+		borderColor: borderColor?.value,
+		borderStyle: borderStyle?.value,
+		borderRadius: borderRadius?.value,
+		padding: padding?.value,
+		rounded: rounded?.value,
+		hidden: hidden?.value,
+	};
+}
+
+function getTextOptions(def: values.Value | undefined): Options<AsUiText> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const size = def.value.get('size');
+	if (size) utils.assertNumber(size);
+	const bold = def.value.get('bold');
+	if (bold) utils.assertBoolean(bold);
+	const color = def.value.get('color');
+	if (color) utils.assertString(color);
+	const font = def.value.get('font');
+	if (font) assertStringAndIsIn(font, FONTS);
+
+	return {
+		text: text?.value,
+		size: size?.value,
+		bold: bold?.value,
+		color: color?.value,
+		font: font?.value,
+	};
+}
+
+function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const size = def.value.get('size');
+	if (size) utils.assertNumber(size);
+	const bold = def.value.get('bold');
+	if (bold) utils.assertBoolean(bold);
+	const color = def.value.get('color');
+	if (color) utils.assertString(color);
+	const font = def.value.get('font');
+	if (font) assertStringAndIsIn(font, FONTS);
+	const onClickEv = def.value.get('onClickEv');
+	if (onClickEv) utils.assertFunction(onClickEv);
+
+	return {
+		text: text?.value,
+		size: size?.value,
+		bold: bold?.value,
+		color: color?.value,
+		font: font?.value,
+		onClickEv: async (evId: string) => {
+			if (onClickEv) await call(onClickEv, [values.STR(evId)]);
+		},
+	};
+}
+
+function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertNumber(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const onClick = def.value.get('onClick');
+	if (onClick) utils.assertFunction(onClick);
+	const primary = def.value.get('primary');
+	if (primary) utils.assertBoolean(primary);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+	const disabled = def.value.get('disabled');
+	if (disabled) utils.assertBoolean(disabled);
+
+	return {
+		text: text?.value,
+		onClick: async () => {
+			if (onClick) await call(onClick, []);
+		},
+		primary: primary?.value,
+		rounded: rounded?.value,
+		disabled: disabled?.value,
+	};
+}
+
+function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> {
+	utils.assertObject(def);
+
+	const buttons = def.value.get('buttons');
+	if (buttons) utils.assertArray(buttons);
+
+	return {
+		buttons: buttons ? buttons.value.map(button => {
+			utils.assertObject(button);
+			const text = button.value.get('text');
+			utils.assertString(text);
+			const onClick = button.value.get('onClick');
+			utils.assertFunction(onClick);
+			const primary = button.value.get('primary');
+			if (primary) utils.assertBoolean(primary);
+			const rounded = button.value.get('rounded');
+			if (rounded) utils.assertBoolean(rounded);
+			const disabled = button.value.get('disabled');
+			if (disabled) utils.assertBoolean(disabled);
+
+			return {
+				text: text.value,
+				onClick: async () => {
+					await call(onClick, []);
+				},
+				primary: primary?.value,
+				rounded: rounded?.value,
+				disabled: disabled?.value,
+			};
+		}) : [],
+	};
+}
+
+function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> {
+	utils.assertObject(def);
+
+	const onChange = def.value.get('onChange');
+	if (onChange) utils.assertFunction(onChange);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertBoolean(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onChange: async (v) => {
+			if (onChange) await call(onChange, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> {
+	utils.assertObject(def);
+
+	const items = def.value.get('items');
+	if (items) utils.assertArray(items);
+	const onChange = def.value.get('onChange');
+	if (onChange) utils.assertFunction(onChange);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		items: items ? items.value.map(item => {
+			utils.assertObject(item);
+			const text = item.value.get('text');
+			utils.assertString(text);
+			const value = item.value.get('value');
+			if (value) utils.assertString(value);
+			return {
+				text: text.value,
+				value: value ? value.value : text.value,
+			};
+		}) : [],
+		onChange: async (v) => {
+			if (onChange) await call(onChange, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> {
+	utils.assertObject(def);
+
+	const children = def.value.get('children');
+	if (children) utils.assertArray(children);
+	const title = def.value.get('title');
+	if (title) utils.assertString(title);
+	const opened = def.value.get('opened');
+	if (opened) utils.assertBoolean(opened);
+
+	return {
+		children: children ? children.value.map(v => {
+			utils.assertObject(v);
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
+		}) : [],
+		title: title?.value ?? '',
+		opened: opened?.value ?? true,
+	};
+}
+
+function getPostFormProps(form: values.VObj): PostFormPropsForAsUi {
+	const text = form.value.get('text');
+	utils.assertString(text);
+	const cw = form.value.get('cw');
+	if (cw) utils.assertString(cw);
+	const visibility = form.value.get('visibility');
+	if (visibility) utils.assertString(visibility);
+	const localOnly = form.value.get('localOnly');
+	if (localOnly) utils.assertBoolean(localOnly);
+
+	return {
+		text: text.value,
+		cw: cw?.value,
+		visibility: (visibility?.value && (Misskey.noteVisibilities as readonly string[]).includes(visibility.value)) ? visibility.value as typeof Misskey.noteVisibilities[number] : undefined,
+		localOnly: localOnly?.value,
+	};
+}
+
+function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const primary = def.value.get('primary');
+	if (primary) utils.assertBoolean(primary);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+	const form = def.value.get('form');
+	if (form) utils.assertObject(form);
+
+	return {
+		text: text?.value,
+		primary: primary?.value,
+		rounded: rounded?.value,
+		form: form ? getPostFormProps(form) : {
+			text: '',
+		},
+	};
+}
+
+function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> {
+	utils.assertObject(def);
+
+	const form = def.value.get('form');
+	if (form) utils.assertObject(form);
+
+	return {
+		form: form ? getPostFormProps(form) : {
+			text: '',
+		},
+	};
+}
+
+export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
+	type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
+
+	const instances = {};
+
+	function createComponentInstance<T extends AsUiComponent, C>(
+		type: T['type'],
+		def: values.Value | undefined,
+		id: values.Value | undefined,
+		getOptions: OptionsConverter<T, C>,
+		call: C,
+	) {
+		if (id) utils.assertString(id);
+		const _id = id?.value ?? uuid();
+		const component = ref({
+			...getOptions(def, call),
+			type,
+			id: _id,
+		} as T);
+		components.push(component);
+		const instance = values.OBJ(new Map<string, values.Value>([
+			['id', values.STR(_id)],
+			['update', values.FN_NATIVE(([def], opts) => {
+				utils.assertObject(def);
+				const updates = getOptions(def, call);
+				for (const update of def.value.keys()) {
+					if (!Object.hasOwn(updates, update)) continue;
+					component.value[update] = updates[update];
+				}
+			})],
+		]));
+		instances[_id] = instance;
+		return instance;
+	}
+
+	const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {});
+	const rootComponent = components[0] as Ref<AsUiRoot>;
+	done(rootComponent);
+
+	return {
+		'Ui:root': rootInstance,
+
+		'Ui:patch': values.FN_NATIVE(([id, val], opts) => {
+			utils.assertString(id);
+			utils.assertArray(val);
+			// patch(id.value, val.value, opts.call); // TODO
+		}),
+
+		'Ui:get': values.FN_NATIVE(([id], opts) => {
+			utils.assertString(id);
+			const instance = instances[id.value];
+			if (instance) {
+				return instance;
+			} else {
+				return values.NULL;
+			}
+		}),
+
+		// Ui:root.update({ children: [...] }) の糖衣構文
+		'Ui:render': values.FN_NATIVE(([children], opts) => {
+			utils.assertArray(children);
+
+			rootComponent.value.children = children.value.map(v => {
+				utils.assertObject(v);
+				const id = v.value.get('id');
+				utils.assertString(id);
+				return id.value;
+			});
+		}),
+
+		'Ui:C:container': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('container', def, id, getContainerOptions, opts.topCall);
+		}),
+
+		'Ui:C:text': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('text', def, id, getTextOptions, opts.topCall);
+		}),
+
+		'Ui:C:mfm': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('mfm', def, id, getMfmOptions, opts.topCall);
+		}),
+
+		'Ui:C:textarea': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('textarea', def, id, getTextareaOptions, opts.topCall);
+		}),
+
+		'Ui:C:textInput': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('textInput', def, id, getTextInputOptions, opts.topCall);
+		}),
+
+		'Ui:C:numberInput': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.topCall);
+		}),
+
+		'Ui:C:button': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('button', def, id, getButtonOptions, opts.topCall);
+		}),
+
+		'Ui:C:buttons': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('buttons', def, id, getButtonsOptions, opts.topCall);
+		}),
+
+		'Ui:C:switch': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('switch', def, id, getSwitchOptions, opts.topCall);
+		}),
+
+		'Ui:C:select': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('select', def, id, getSelectOptions, opts.topCall);
+		}),
+
+		'Ui:C:folder': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('folder', def, id, getFolderOptions, opts.topCall);
+		}),
+
+		'Ui:C:postFormButton': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.topCall);
+		}),
+
+		'Ui:C:postForm': values.FN_NATIVE(([def, id], opts) => {
+			return createComponentInstance('postForm', def, id, getPostFormOptions, opts.topCall);
+		}),
+	};
+}
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 7b35d12a80..d66ff21519 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -12,16 +12,16 @@ import type { App } from 'vue';
 import widgets from '@/widgets/index.js';
 import directives from '@/directives/index.js';
 import components from '@/components/index.js';
-import { applyTheme } from '@/scripts/theme.js';
-import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
+import { applyTheme } from '@/utility/theme.js';
+import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
 import { updateI18n, i18n } from '@/i18n.js';
 import { $i, refreshAccount, login } from '@/account.js';
 import { store } from '@/store.js';
 import { fetchInstance, instance } from '@/instance.js';
-import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js';
-import { reloadChannel } from '@/scripts/unison-reload.js';
-import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
-import { getAccountFromId } from '@/scripts/get-account-from-id.js';
+import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js';
+import { reloadChannel } from '@/utility/unison-reload.js';
+import { getUrlWithoutLoginId } from '@/utility/login-id.js';
+import { getAccountFromId } from '@/utility/get-account-from-id.js';
 import { deckStore } from '@/ui/deck/deck-store.js';
 import { analytics, initAnalytics } from '@/analytics.js';
 import { miLocalStorage } from '@/local-storage.js';
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 0ebe55ed1d..94a57acb96 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -8,24 +8,24 @@ import { ui } from '@@/js/config.js';
 import * as Misskey from 'misskey-js';
 import { common } from './common.js';
 import type { Component } from 'vue';
-import type { Keymap } from '@/scripts/hotkey.js';
+import type { Keymap } from '@/utility/hotkey.js';
 import { i18n } from '@/i18n.js';
 import { alert, confirm, popup, post, toast } from '@/os.js';
 import { useStream } from '@/stream.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 import { $i, signout, updateAccountPartial } from '@/account.js';
 import { instance } from '@/instance.js';
 import { ColdDeviceStorage, store } from '@/store.js';
-import { reactionPicker } from '@/scripts/reaction-picker.js';
+import { reactionPicker } from '@/utility/reaction-picker.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
-import { initializeSw } from '@/scripts/initialize-sw.js';
-import { emojiPicker } from '@/scripts/emoji-picker.js';
+import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
+import { initializeSw } from '@/utility/initialize-sw.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
 import { mainRouter } from '@/router/main.js';
-import { makeHotkey } from '@/scripts/hotkey.js';
+import { makeHotkey } from '@/utility/hotkey.js';
 import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
 import { prefer } from '@/preferences.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { deckStore } from '@/ui/deck/deck-store.js';
 import { launchPlugin } from '@/plugin.js';
 
@@ -115,16 +115,16 @@ export async function mainBoot() {
 			if (prefer.s.hemisphere === 'S') {
 				// ▼南半球
 				if (month === 7 || month === 8) {
-					const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+					const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
 					new SnowfallEffect({}).render();
 				}
 			} else {
 				// ▼北半球
 				if (month === 12 || month === 1) {
-					const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+					const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
 					new SnowfallEffect({}).render();
 				} else if (month === 3 || month === 4) {
-					const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect;
+					const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect;
 					new SakuraEffect({
 						sakura: true,
 					}).render();
diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts
index 35c84d5568..e24c324dfb 100644
--- a/packages/frontend/src/boot/sub-boot.ts
+++ b/packages/frontend/src/boot/sub-boot.ts
@@ -5,7 +5,7 @@
 
 import { createApp, defineAsyncComponent } from 'vue';
 import { common } from './common.js';
-import { emojiPicker } from '@/scripts/emoji-picker.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
 
 export async function subBoot() {
 	const { isClientUpdated } = await common(() => createApp(
diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts
index bfe8fbe0e4..70078b410d 100644
--- a/packages/frontend/src/cache.ts
+++ b/packages/frontend/src/cache.ts
@@ -4,8 +4,8 @@
  */
 
 import * as Misskey from 'misskey-js';
-import { Cache } from '@/scripts/cache.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { Cache } from '@/utility/cache.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list'));
 export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index e48b6ef781..8e37317779 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -90,7 +90,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import RouterView from '@/components/global/RouterView.vue';
 import { useRouterFactory } from '@/router/supplier';
 import MkTextarea from '@/components/MkTextarea.vue';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 
 const props = defineProps<{
 	report: Misskey.entities.AdminAbuseUserReportsResponse[number];
diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue
index 0839955d9d..cb8032c019 100644
--- a/packages/frontend/src/components/MkAccountMoved.vue
+++ b/packages/frontend/src/components/MkAccountMoved.vue
@@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js';
 import MkMention from './MkMention.vue';
 import { i18n } from '@/i18n.js';
 import { host as localHost } from '@@/js/config.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const user = ref<Misskey.entities.UserLite>();
 
diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts
index bbd3f69d7c..d838997616 100644
--- a/packages/frontend/src/components/MkAchievements.stories.impl.ts
+++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts
@@ -9,7 +9,7 @@ import { HttpResponse, http } from 'msw';
 import { userDetailed } from '../../.storybook/fakes.js';
 import { commonHandlers } from '../../.storybook/mocks.js';
 import MkAchievements from './MkAchievements.vue';
-import { ACHIEVEMENT_TYPES } from '@/scripts/achievements.js';
+import { ACHIEVEMENT_TYPES } from '@/utility/achievements.js';
 export const Empty = {
 	render(args) {
 		return {
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue
index c8134416b5..70766634ce 100644
--- a/packages/frontend/src/components/MkAchievements.vue
+++ b/packages/frontend/src/components/MkAchievements.vue
@@ -55,9 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { onMounted, ref, computed } from 'vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js';
+import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js';
 
 const props = withDefaults(defineProps<{
 	user: Misskey.entities.User;
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
index c8fa6246e0..b39bca5b27 100644
--- a/packages/frontend/src/components/MkAnalogClock.vue
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
 import tinycolor from 'tinycolor2';
 import { globalEvents } from '@/events.js';
-import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
+import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js';
 
 // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
 const angleDiff = (a: number, b: number) => {
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 3045a47585..41fd2564d8 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue
index e622d57f1e..ac71618ee2 100644
--- a/packages/frontend/src/components/MkAntennaEditor.vue
+++ b/packages/frontend/src/components/MkAntennaEditor.vue
@@ -59,10 +59,10 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { deepMerge } from '@/scripts/merge.js';
-import type { DeepPartial } from '@/scripts/merge.js';
+import { deepMerge } from '@/utility/merge.js';
+import type { DeepPartial } from '@/utility/merge.js';
 
 type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
 	id?: string;
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 5c4d887e0c..20a953c72c 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkSelect from '@/components/MkSelect.vue';
-import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js';
+import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
 
diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue
index f78d2d38f0..090c31044e 100644
--- a/packages/frontend/src/components/MkAuthConfirm.vue
+++ b/packages/frontend/src/components/MkAuthConfirm.vue
@@ -123,8 +123,8 @@ import MkButton from '@/components/MkButton.vue';
 import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { getProxiedImageUrl } from '@/utility/media-proxy.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const props = defineProps<{
 	name?: string;
diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts
index af5dd4784d..64ccb708aa 100644
--- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts
+++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts
@@ -12,7 +12,7 @@ import { userDetailed } from '../../.storybook/fakes.js';
 import { commonHandlers } from '../../.storybook/mocks.js';
 import MkAutocomplete from './MkAutocomplete.vue';
 import MkInput from './MkInput.vue';
-import { tick } from '@/scripts/test-utils.js';
+import { tick } from '@/utility/test-utils.js';
 const common = {
 	render(args) {
 		return {
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 1a68353331..912e2d65a5 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -49,16 +49,16 @@ import sanitizeHtml from 'sanitize-html';
 import { emojilist, getEmojiName } from '@@/js/emojilist.js';
 import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
 import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
-import type { EmojiDef } from '@/scripts/search-emoji.js';
-import contains from '@/scripts/contains.js';
+import type { EmojiDef } from '@/utility/search-emoji.js';
+import contains from '@/utility/contains.js';
 import { acct } from '@/filters/user.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
-import { searchEmoji } from '@/scripts/search-emoji.js';
+import { searchEmoji } from '@/utility/search-emoji.js';
 import { prefer } from '@/preferences.js';
 
 const lib = emojilist.filter(x => x.category !== 'flags');
diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue
index 8236d0ddb9..1c44ed60d8 100644
--- a/packages/frontend/src/components/MkAvatars.vue
+++ b/packages/frontend/src/components/MkAvatars.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const props = withDefaults(defineProps<{
 	userIds: string[];
diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue
index d4e4f6179a..2ef2d84198 100644
--- a/packages/frontend/src/components/MkChannelFollowButton.vue
+++ b/packages/frontend/src/components/MkChannelFollowButton.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index c8685f8618..4707d02ce9 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -53,15 +53,15 @@ export type ChartSrc =
 import { onMounted, ref, shallowRef, watch } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { alpha } from '@/scripts/color.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { alpha } from '@/utility/color.js';
 import date from '@/filters/date.js';
 import bytes from '@/filters/bytes.js';
-import { initChart } from '@/scripts/init-chart.js';
-import { chartLegend } from '@/scripts/chart-legend.js';
+import { initChart } from '@/utility/init-chart.js';
+import { chartLegend } from '@/utility/chart-legend.js';
 import MkChartLegend from '@/components/MkChartLegend.vue';
 
 initChart();
diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue
index 9a0a9fba05..775964af50 100644
--- a/packages/frontend/src/components/MkClickerGame.vue
+++ b/packages/frontend/src/components/MkClickerGame.vue
@@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
 import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
 import * as os from '@/os.js';
 import { useInterval } from '@@/js/use-interval.js';
-import * as game from '@/scripts/clicker-game.js';
+import * as game from '@/utility/clicker-game.js';
 import number from '@/filters/number.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 
 const saveData = game.saveData;
 const cookies = computed(() => saveData.value?.cookies);
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index a9001b376a..2fc8c6591e 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref, watch } from 'vue';
 import { bundledLanguagesInfo } from 'shiki/langs';
 import type { BundledLanguage } from 'shiki/langs';
-import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
+import { getHighlighter, getTheme } from '@/utility/code-highlighter.js';
 import { store } from '@/store.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index 06e4d32878..9708b78a30 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue';
 import * as os from '@/os.js';
 import MkLoading from '@/components/global/MkLoading.vue';
 import { i18n } from '@/i18n.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index ed9e5127b0..46da0840fa 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
-import contains from '@/scripts/contains.js';
+import contains from '@/utility/contains.js';
 import { prefer } from '@/preferences.js';
 import * as os from '@/os.js';
 
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 9f9c5b32e2..3c41d597de 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -40,7 +40,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
 import * as os from '@/os.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
 
 const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue
index b5f6e78b6c..cc8bbf1104 100644
--- a/packages/frontend/src/components/MkCwButton.vue
+++ b/packages/frontend/src/components/MkCwButton.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
-import { concat } from '@/scripts/array.js';
+import { concat } from '@/utility/array.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 
diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue
index 2e2321e6ac..8198356a76 100644
--- a/packages/frontend/src/components/MkDigitalClock.vue
+++ b/packages/frontend/src/components/MkDigitalClock.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref, watch } from 'vue';
-import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
+import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js';
 
 const props = withDefaults(defineProps<{
 	showS?: boolean;
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index e45c3bd9ce..733d50728e 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -45,8 +45,8 @@ import bytes from '@/filters/bytes.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index f91b248d0c..7b8d81f7f0 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -38,10 +38,10 @@ import { computed, defineAsyncComponent, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { claimAchievement } from '@/scripts/achievements.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { claimAchievement } from '@/utility/achievements.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue
index 8df3c86ebf..7433aea061 100644
--- a/packages/frontend/src/components/MkDrive.navFolder.vue
+++ b/packages/frontend/src/components/MkDrive.navFolder.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 6863e65b05..270ef51916 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -104,11 +104,11 @@ import XNavFolder from '@/components/MkDrive.navFolder.vue';
 import XFolder from '@/components/MkDrive.folder.vue';
 import XFile from '@/components/MkDrive.file.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { uploadFile, uploads } from '@/scripts/upload.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { uploadFile, uploads } from '@/utility/upload.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index 6e9eb75920..a53c9c7904 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -105,8 +105,8 @@ import MkInfo from '@/components/MkInfo.vue';
 
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
 
 const emit = defineEmits<{
 	(ev: 'ok'): void;
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 3c5b3133da..214b244802 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -131,13 +131,13 @@ import type {
 import XSection from '@/components/MkEmojiPicker.section.vue';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import * as os from '@/os.js';
-import { isTouchUsing } from '@/scripts/touch.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { isTouchUsing } from '@/utility/touch.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import { i18n } from '@/i18n.js';
 import { store } from '@/store.js';
 import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
 import { $i } from '@/account.js';
-import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
+import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue
index 08b8d60d09..554586b2f3 100644
--- a/packages/frontend/src/components/MkFoldableSection.vue
+++ b/packages/frontend/src/components/MkFoldableSection.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, ref, shallowRef, watch } from 'vue';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
-import { getBgColor } from '@/scripts/get-bg-color.js';
+import { getBgColor } from '@/utility/get-bg-color.js';
 
 const miLocalStoragePrefix = 'ui:folder:' as const;
 
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 1f27e9e221..1e2057d28e 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { nextTick, onMounted, ref, shallowRef } from 'vue';
 import { prefer } from '@/preferences.js';
-import { getBgColor } from '@/scripts/get-bg-color.js';
+import { getBgColor } from '@/utility/get-bg-color.js';
 
 const props = withDefaults(defineProps<{
 	defaultOpen?: boolean;
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index bd45df843a..64828699d6 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -39,11 +39,11 @@ import { onBeforeUnmount, onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { host } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { claimAchievement } from '@/scripts/achievements.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
+import { claimAchievement } from '@/utility/achievements.js';
+import { pleaseLogin } from '@/utility/please-login.js';
 import { $i } from '@/account.js';
 import { store } from '@/store.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue
index ecb6cf882b..0a902f3400 100644
--- a/packages/frontend/src/components/MkFormDialog.file.vue
+++ b/packages/frontend/src/components/MkFormDialog.file.vue
@@ -15,8 +15,8 @@ import * as Misskey from 'misskey-js';
 import { computed, ref } from 'vue';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
-import { selectFile } from '@/scripts/select-file.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { selectFile } from '@/utility/select-file.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const props = defineProps<{
 	fileId?: string | null;
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index a639eae208..8fd16e13d4 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -80,7 +80,7 @@ import MkRange from './MkRange.vue';
 import MkButton from './MkButton.vue';
 import MkRadios from './MkRadios.vue';
 import XFile from './MkFormDialog.file.vue';
-import type { Form } from '@/scripts/form.js';
+import type { Form } from '@/utility/form.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
 import { infoImageUrl } from '@/instance.js';
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 058a2d7c78..4c4ae86db3 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -16,11 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
index ca597b43cb..ad85660fc4 100644
--- a/packages/frontend/src/components/MkHorizontalSwipe.vue
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, shallowRef, computed, nextTick, watch } from 'vue';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
-import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js';
+import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
 import { prefer } from '@/preferences.js';
 
 const rootEl = shallowRef<HTMLDivElement>();
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index 739061bce1..aff9a67020 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -50,8 +50,8 @@ import { debounce } from 'throttle-debounce';
 import MkButton from '@/components/MkButton.vue';
 import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
-import { Autocomplete } from '@/scripts/autocomplete.js';
-import type { SuggestionType } from '@/scripts/autocomplete.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
+import type { SuggestionType } from '@/utility/autocomplete.js';
 
 const props = defineProps<{
 	modelValue: string | number | null;
diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue
index b0601cf7f9..7902151921 100644
--- a/packages/frontend/src/components/MkInstanceCardMini.vue
+++ b/packages/frontend/src/components/MkInstanceCardMini.vue
@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkMiniChart from '@/components/MkMiniChart.vue';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const props = defineProps<{
 	instance: Misskey.entities.FederationInstance;
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index c2860ed89b..9d475bc8aa 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -88,10 +88,10 @@ import { onMounted, ref, computed, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
 import { $i } from '@/account.js';
 import * as os from '@/os.js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import MkHeatmap from '@/components/MkHeatmap.vue';
@@ -99,7 +99,7 @@ import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
 import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
-import { initChart } from '@/scripts/init-chart.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue
index 1da3f14ad4..eba8a73aec 100644
--- a/packages/frontend/src/components/MkInstanceTicker.vue
+++ b/packages/frontend/src/components/MkInstanceTicker.vue
@@ -15,7 +15,7 @@ import { computed } from 'vue';
 import type { CSSProperties } from 'vue';
 import { instanceName as localInstanceName } from '@@/js/config.js';
 import { instance as localInstance } from '@/instance.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const props = defineProps<{
 	host: string | null;
diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue
index 1a71f6574f..08eddfc03c 100644
--- a/packages/frontend/src/components/MkInviteCode.vue
+++ b/packages/frontend/src/components/MkInviteCode.vue
@@ -64,7 +64,7 @@ import { computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkButton from '@/components/MkButton.vue';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 
diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue
index 50c9e16e5e..6a4b298523 100644
--- a/packages/frontend/src/components/MkKeyValue.vue
+++ b/packages/frontend/src/components/MkKeyValue.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { } from 'vue';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 9538979dd1..9a78eb67f2 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { shallowRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import { navbarItemDef } from '@/navbar.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
index a276bf68b4..140fc9816c 100644
--- a/packages/frontend/src/components/MkLink.vue
+++ b/packages/frontend/src/components/MkLink.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent, ref } from 'vue';
 import { url as local } from '@@/js/config.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
 import * as os from '@/os.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 83ce212efd..33d4d269e7 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -91,8 +91,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
-import type { Keymap } from '@/scripts/hotkey.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard';
+import type { Keymap } from '@/utility/hotkey.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import bytes from '@/filters/bytes.js';
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 8206e3d8f6..6029b1e0b6 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -54,8 +54,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { watch, ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import bytes from '@/filters/bytes.js';
 import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 1c9494f8d6..8a7a423869 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -38,7 +38,7 @@ import XBanner from '@/components/MkMediaBanner.vue';
 import XImage from '@/components/MkMediaImage.vue';
 import XVideo from '@/components/MkMediaVideo.vue';
 import * as os from '@/os.js';
-import { focusParent } from '@/scripts/focus.js';
+import { focusParent } from '@/utility/focus.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 1433b17dc0..74c1aefc3a 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -112,14 +112,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
-import type { Keymap } from '@/scripts/hotkey.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard';
+import type { Keymap } from '@/utility/hotkey.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js';
-import hasAudio from '@/scripts/media-has-audio.js';
+import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
+import hasAudio from '@/utility/media-has-audio.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
 import { $i, iAmModerator } from '@/account.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index ff3442abe0..419407955f 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -19,7 +19,7 @@ import { computed } from 'vue';
 import { host as localHost } from '@@/js/config.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
 import { $i } from '@/account.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index d484c1b338..61b3fa2fee 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -181,10 +181,10 @@ import MkSwitchButton from '@/components/MkSwitch.button.vue';
 import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { isTouchUsing } from '@/scripts/touch.js';
-import type { Keymap } from '@/scripts/hotkey.js';
-import { isFocusable } from '@/scripts/focus.js';
-import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
+import { isTouchUsing } from '@/utility/touch.js';
+import type { Keymap } from '@/utility/hotkey.js';
+import { isFocusable } from '@/utility/focus.js';
+import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js';
 
 const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
 </script>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 521c851d8b..8b3086d55e 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -43,12 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue';
-import type { Keymap } from '@/scripts/hotkey.js';
+import type { Keymap } from '@/utility/hotkey.js';
 import * as os from '@/os.js';
-import { isTouchUsing } from '@/scripts/touch.js';
-import { deviceKind } from '@/scripts/device-kind.js';
-import { focusTrap } from '@/scripts/focus-trap.js';
-import { focusParent } from '@/scripts/focus.js';
+import { isTouchUsing } from '@/utility/touch.js';
+import { deviceKind } from '@/utility/device-kind.js';
+import { focusTrap } from '@/utility/focus-trap.js';
+import { focusParent } from '@/utility/focus.js';
 import { prefer } from '@/preferences.js';
 
 function getFixedContainer(el: Element | null): Element | null {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index c88b5c16ee..0daa917500 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -185,8 +185,8 @@ import { shouldCollapsed } from '@@/js/collapsed.js';
 import { host } from '@@/js/config.js';
 import type { Ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
-import type { Keymap } from '@/scripts/hotkey.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
+import type { Keymap } from '@/utility/hotkey.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
@@ -198,30 +198,30 @@ import MkPoll from '@/components/MkPoll.vue';
 import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
-import { pleaseLogin } from '@/scripts/please-login.js';
-import { checkWordMute } from '@/scripts/check-word-mute.js';
+import { pleaseLogin } from '@/utility/please-login.js';
+import { checkWordMute } from '@/utility/check-word-mute.js';
 import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
 import number from '@/filters/number.js';
 import * as os from '@/os.js';
-import * as sound from '@/scripts/sound.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import * as sound from '@/utility/sound.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { noteViewInterruptors } from '@/store.js';
-import { reactionPicker } from '@/scripts/reaction-picker.js';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { reactionPicker } from '@/utility/reaction-picker.js';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
-import { useNoteCapture } from '@/scripts/use-note-capture.js';
-import { deepClone } from '@/scripts/clone.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
-import { claimAchievement } from '@/scripts/achievements.js';
-import { getNoteSummary } from '@/scripts/get-note-summary.js';
+import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
+import { useNoteCapture } from '@/utility/use-note-capture.js';
+import { deepClone } from '@/utility/clone.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
+import { claimAchievement } from '@/utility/achievements.js';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
-import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
+import { showMovedDialog } from '@/utility/show-moved-dialog.js';
 import { isEnabledUrlPreview } from '@/instance.js';
-import { focusPrev, focusNext } from '@/scripts/focus.js';
-import { getAppearNote } from '@/scripts/get-appear-note.js';
+import { focusPrev, focusNext } from '@/utility/focus.js';
+import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 75c08087f3..10f375fcae 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -215,9 +215,9 @@ import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
 import { host } from '@@/js/config.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
 import type { Paging } from '@/components/MkPagination.vue';
-import type { Keymap } from '@/scripts/hotkey.js';
+import type { Keymap } from '@/utility/hotkey.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@@ -228,32 +228,32 @@ import MkPoll from '@/components/MkPoll.vue';
 import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
-import { pleaseLogin } from '@/scripts/please-login.js';
-import { checkWordMute } from '@/scripts/check-word-mute.js';
+import { pleaseLogin } from '@/utility/please-login.js';
+import { checkWordMute } from '@/utility/check-word-mute.js';
 import { userPage } from '@/filters/user.js';
 import { notePage } from '@/filters/note.js';
 import number from '@/filters/number.js';
 import * as os from '@/os.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import * as sound from '@/scripts/sound.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
+import * as sound from '@/utility/sound.js';
 import { noteViewInterruptors } from '@/store.js';
-import { reactionPicker } from '@/scripts/reaction-picker.js';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { reactionPicker } from '@/utility/reaction-picker.js';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
-import { useNoteCapture } from '@/scripts/use-note-capture.js';
-import { deepClone } from '@/scripts/clone.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
+import { useNoteCapture } from '@/utility/use-note-capture.js';
+import { deepClone } from '@/utility/clone.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
-import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
+import { showMovedDialog } from '@/utility/show-moved-dialog.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
-import { getAppearNote } from '@/scripts/get-appear-note.js';
+import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index e4bade309b..bb7347cd26 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -46,11 +46,11 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
 import { notePage } from '@/filters/note.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { userPage } from '@/filters/user.js';
-import { checkWordMute } from '@/scripts/check-word-mute.js';
+import { checkWordMute } from '@/utility/check-word-mute.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 80cb9a45bb..5d096cf92d 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -164,11 +164,11 @@ import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import MkButton from '@/components/MkButton.vue';
-import { getNoteSummary } from '@/scripts/get-note-summary.js';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
 import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { signinRequired } from '@/account.js';
 import { infoImageUrl } from '@/instance.js';
 
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index c3fc1961eb..780b434456 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -32,16 +32,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
 import { url } from '@@/js/config.js';
 import { getScrollContainer } from '@@/js/scroll.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import RouterView from '@/components/global/RouterView.vue';
 import MkWindow from '@/components/MkWindow.vue';
-import { popout as _popout } from '@/scripts/popout.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { popout as _popout } from '@/utility/popout.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useScrollPositionManager } from '@/nirax.js';
 import { i18n } from '@/i18n.js';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
 import { openingWindowsCount } from '@/os.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { useRouterFactory } from '@/router/supplier.js';
 import { mainRouter } from '@/router/main.js';
 import { analytics } from '@/analytics.js';
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index d4b0180ef9..1253d7883f 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -49,7 +49,7 @@ import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
 import type { ComputedRef } from 'vue';
 import type { MisskeyEntity } from '@/types/date-separated-list.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index 1b2b3e48ba..2d3ec45bca 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -31,11 +31,11 @@ import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { host } from '@@/js/config.js';
 import { useInterval } from '@@/js/use-interval.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
-import { sum } from '@/scripts/array.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
+import { sum } from '@/utility/array.js';
+import { pleaseLogin } from '@/utility/please-login.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue
index 3726ddf822..22fe189a63 100644
--- a/packages/frontend/src/components/MkPollEditor.vue
+++ b/packages/frontend/src/components/MkPollEditor.vue
@@ -58,8 +58,8 @@ import MkInput from './MkInput.vue';
 import MkSelect from './MkSelect.vue';
 import MkSwitch from './MkSwitch.vue';
 import MkButton from './MkButton.vue';
-import { formatDateTimeString } from '@/scripts/format-time-string.js';
-import { addTime } from '@/scripts/time.js';
+import { formatDateTimeString } from '@/utility/format-time-string.js';
+import { addTime } from '@/utility/time.js';
 import { i18n } from '@/i18n.js';
 
 export type PollEditorModelValue = {
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6aa460b506..306c4be61b 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -116,25 +116,25 @@ import MkNotePreview from '@/components/MkNotePreview.vue';
 import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
 import MkPollEditor from '@/components/MkPollEditor.vue';
 import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
-import { erase, unique } from '@/scripts/array.js';
-import { extractMentions } from '@/scripts/extract-mentions.js';
-import { formatTimeString } from '@/scripts/format-time-string.js';
-import { Autocomplete } from '@/scripts/autocomplete.js';
+import { erase, unique } from '@/utility/array.js';
+import { extractMentions } from '@/utility/extract-mentions.js';
+import { formatTimeString } from '@/utility/format-time-string.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { selectFiles } from '@/scripts/select-file.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { selectFiles } from '@/utility/select-file.js';
 import { store, notePostInterruptors, postFormActions } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { uploadFile } from '@/scripts/upload.js';
-import { deepClone } from '@/scripts/clone.js';
+import { uploadFile } from '@/utility/upload.js';
+import { deepClone } from '@/utility/clone.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { miLocalStorage } from '@/local-storage.js';
-import { claimAchievement } from '@/scripts/achievements.js';
-import { emojiPicker } from '@/scripts/emoji-picker.js';
-import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
+import { claimAchievement } from '@/utility/achievements.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
+import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
 import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 83d0d66cc3..3ce0c4957b 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -36,10 +36,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent, inject } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard';
+import { copyToClipboard } from '@/utility/copy-to-clipboard';
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index 4fb4c6fe56..af4e2a4fe0 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
 import { i18n } from '@/i18n.js';
 import { getScrollContainer } from '@@/js/scroll.js';
-import { isHorizontalSwipeSwiping } from '@/scripts/touch.js';
+import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
 
 const SCROLL_STOP = 10;
 const MAX_PULL_DISTANCE = Infinity;
diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
index 5e42df4795..780f8bc6d0 100644
--- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue
+++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
@@ -46,7 +46,7 @@ import { $i, getAccounts } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
 import { instance } from '@/instance.js';
 import { apiWithDialog, promiseDialog } from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 defineProps<{
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 264b559222..8e5be38e03 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
-import { isTouchUsing } from '@/scripts/touch.js';
+import { isTouchUsing } from '@/utility/touch.js';
 import * as os from '@/os.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index c0cbd8a65d..cb25580bc2 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { defineAsyncComponent, shallowRef } from 'vue';
-import { useTooltip } from '@/scripts/use-tooltip.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
 import * as os from '@/os.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 2e453aeb8f..f35c938d02 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -25,14 +25,14 @@ import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
 import XDetails from '@/components/MkReactionsViewer.details.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import * as os from '@/os.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
 import { $i } from '@/account.js';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { i18n } from '@/i18n.js';
-import * as sound from '@/scripts/sound.js';
-import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
+import * as sound from '@/utility/sound.js';
+import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
 import { customEmojisMap } from '@/custom-emojis.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 3ca680d8f2..1dc1da0bc3 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, nextTick, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 13cee1b1cd..dd4a8a1a6d 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -12,11 +12,11 @@ import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 initChart();
 
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue
index 32f35ed5ad..229fd9d1ba 100644
--- a/packages/frontend/src/components/MkRoleSelectDialog.vue
+++ b/packages/frontend/src/components/MkRoleSelectDialog.vue
@@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkRolePreview from '@/components/MkRolePreview.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import * as os from '@/os.js';
 import MkSpacer from '@/components/global/MkSpacer.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue
index e98ac9cfd2..aacd1eae2a 100644
--- a/packages/frontend/src/components/MkSignin.input.vue
+++ b/packages/frontend/src/components/MkSignin.input.vue
@@ -58,7 +58,7 @@ import { toUnicode } from 'punycode.js';
 
 import { query, extractDomain } from '@@/js/url.js';
 import { host as configHost } from '@@/js/config.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 2fb97e8e46..2dcc87f425 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -69,9 +69,9 @@ import * as Misskey from 'misskey-js';
 import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
 
 import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
 import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
index 676a336ec7..a970d1ce00 100644
--- a/packages/frontend/src/components/MkSigninDialog.vue
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
 import { shallowRef } from 'vue';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
 import MkSignin from '@/components/MkSignin.vue';
 import MkModal from '@/components/MkModal.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 4227e1317a..151cd60fb2 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -88,7 +88,7 @@ import MkInput from './MkInput.vue';
 import MkCaptcha from '@/components/MkCaptcha.vue';
 import type { Captcha } from '@/components/MkCaptcha.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { login } from '@/account.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index ce0fa86219..fddf3934bb 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -94,12 +94,12 @@ export type SuperMenuDef = {
 
 <script lang="ts" setup>
 import { useTemplateRef, ref, watch, nextTick } from 'vue';
-import type { SearchIndexItem } from '@/scripts/autogen/settings-search-index.js';
+import type { SearchIndexItem } from '@/utility/autogen/settings-search-index.js';
 import MkInput from '@/components/MkInput.vue';
 import { i18n } from '@/i18n.js';
 import { getScrollContainer } from '@@/js/scroll.js';
 import { useRouter } from '@/router/supplier.js';
-import { initIntlString, compareStringIncludes } from '@/scripts/intl-string.js';
+import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js';
 
 const props = defineProps<{
 	def: SuperMenuDef[];
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index 7e92726dcb..2e1a96c326 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -103,7 +103,7 @@ import type {
 } from '@/components/MkSystemWebhookEditor.impl.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index 3e8588018c..eb47a8b858 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -40,8 +40,8 @@ import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallow
 import { debounce } from 'throttle-debounce';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { Autocomplete } from '@/scripts/autocomplete.js';
-import type { SuggestionType } from '@/scripts/autocomplete.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
+import type { SuggestionType } from '@/utility/autocomplete.js';
 
 const props = defineProps<{
 	modelValue: string | null;
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 3cc9b69341..62ae9a048a 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -24,7 +24,7 @@ import type { Paging } from '@/components/MkPagination.vue';
 import MkNotes from '@/components/MkNotes.vue';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { useStream } from '@/stream.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 import { $i } from '@/account.js';
 import { instance } from '@/instance.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
index c30ca1ffe6..8031abd563 100644
--- a/packages/frontend/src/components/MkTooltip.vue
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
 import * as os from '@/os.js';
-import { calcPopupPosition } from '@/scripts/popup-position.js';
+import { calcPopupPosition } from '@/utility/popup-position.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue
index 11d7c8dc4d..5908db33c4 100644
--- a/packages/frontend/src/components/MkTutorialDialog.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.vue
@@ -159,7 +159,7 @@ import MkAnimBg from '@/components/MkAnimBg.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { host } from '@@/js/config.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import * as os from '@/os.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue
index c937b4ce59..95732d5796 100644
--- a/packages/frontend/src/components/MkUpdated.vue
+++ b/packages/frontend/src/components/MkUpdated.vue
@@ -21,7 +21,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkSparkle from '@/components/MkSparkle.vue';
 import { version } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
-import { confetti } from '@/scripts/confetti.js';
+import { confetti } from '@/utility/confetti.js';
 
 const modal = shallowRef<InstanceType<typeof MkModal>>();
 
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 850dd2d257..f8ac638e40 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -89,9 +89,9 @@ import { versatileLang } from '@@/js/intl-const.js';
 import type { summaly } from '@misskey-dev/summaly';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import MkButton from '@/components/MkButton.vue';
-import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
+import { transformPlayerUrl } from '@/utility/player-url-transform.js';
 import { store } from '@/store.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
index 34991fa0dd..34e86444ad 100644
--- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
+++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
@@ -56,7 +56,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue
index 7a2e878931..dde2efd8ee 100644
--- a/packages/frontend/src/components/MkUserCardMini.vue
+++ b/packages/frontend/src/components/MkUserCardMini.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { onMounted, ref } from 'vue';
 import MkMiniChart from '@/components/MkMiniChart.vue';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { acct } from '@/filters/user.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index 9abf61e893..eb189b446b 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -40,8 +40,8 @@ import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
 
 defineProps<{
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 4027893a8e..8a89b253e3 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -60,14 +60,14 @@ import * as Misskey from 'misskey-js';
 import MkFollowButton from '@/components/MkFollowButton.vue';
 import { userPage } from '@/filters/user.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { getUserMenu } from '@/scripts/get-user-menu.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { getUserMenu } from '@/utility/get-user-menu.js';
 import number from '@/filters/number.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
-import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 
 const props = defineProps<{
 	showing: boolean;
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index 2c5757e24e..1dbc143f8f 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -67,7 +67,7 @@ import { host as currentHost, hostname } from '@@/js/config.js';
 import MkInput from '@/components/MkInput.vue';
 import FormSplit from '@/components/form/split.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
index 62e5d1da8a..629bc30667 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
@@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const isLocked = ref(false);
 const hideOnlineStatus = ref(false);
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
index 7cb48f6afb..14acfd3f89 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -37,7 +37,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import FormSlot from '@/components/form/slot.vue';
 import MkInfo from '@/components/MkInfo.vue';
-import { chooseFileFromPc } from '@/scripts/select-file.js';
+import { chooseFileFromPc } from '@/utility/select-file.js';
 import * as os from '@/os.js';
 import { signinRequired } from '@/account.js';
 
diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue
index 004edab630..4accc6183b 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.User.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue
@@ -29,7 +29,7 @@ import * as Misskey from 'misskey-js';
 import { ref } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const props = defineProps<{
 	user: Misskey.entities.UserDetailed;
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index ac48b11c3f..3aa76be0bc 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -17,11 +17,11 @@ import { onMounted, shallowRef, ref, nextTick } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index 898ab75fc0..bad0375136 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -62,7 +62,7 @@ import MkTimeline from '@/components/MkTimeline.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { instanceName } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import MkNumber from '@/components/MkNumber.vue';
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 4759d217e8..4b3c728ee4 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
-import contains from '@/scripts/contains.js';
+import contains from '@/utility/contains.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue
index 1c069d65fc..ab62a5113d 100644
--- a/packages/frontend/src/components/MkYouTubePlayer.vue
+++ b/packages/frontend/src/components/MkYouTubePlayer.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import { versatileLang } from '@@/js/intl-const.js';
 import MkWindow from '@/components/MkWindow.vue';
-import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
+import { transformPlayerUrl } from '@/utility/player-url-transform.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts
index 1ccf105dbb..deb2b8a52b 100644
--- a/packages/frontend/src/components/global/MkA.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkA.stories.impl.ts
@@ -7,7 +7,7 @@
 import { expect, userEvent, within } from '@storybook/test';
 import type { StoryObj } from '@storybook/vue3';
 import MkA from './MkA.vue';
-import { tick } from '@/scripts/test-utils.js';
+import { tick } from '@/utility/test-utils.js';
 export const Default = {
 	render(args) {
 		return {
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 8eacf16d6d..173f6a849f 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -17,7 +17,7 @@ export type MkABehavior = 'window' | 'browser' | null;
 import { computed, inject, shallowRef } from 'vue';
 import { url } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 71c309fd89..dc129b4c30 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -45,7 +45,7 @@ import * as Misskey from 'misskey-js';
 import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
 import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
 import MkA from './MkA.vue';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { acct, userPage } from '@/filters/user.js';
 import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index 82c5e2a1dc..9981092dff 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -27,12 +27,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, defineAsyncComponent, inject, ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
-import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrl, getStaticImageUrl } from '@/utility/media-proxy.js';
 import { customEmojisMap } from '@/custom-emojis.js';
 import * as os from '@/os.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import * as sound from '@/scripts/sound.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as sound from '@/utility/sound.js';
 import { i18n } from '@/i18n.js';
 import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
 import { $i } from '@/account.js';
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
index a00004fdb8..37df945233 100644
--- a/packages/frontend/src/components/global/MkEmoji.vue
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -14,8 +14,8 @@ import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js';
 import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js';
 import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import * as sound from '@/scripts/sound.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as sound from '@/utility/sound.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 1070c0c83b..a7cf70745e 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -47,10 +47,10 @@ import XTabs from './MkPageHeader.tabs.vue';
 import type { Tab } from './MkPageHeader.tabs.vue';
 import { scrollToTop } from '@@/js/scroll.js';
 import { globalEvents } from '@/events.js';
-import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
+import { injectReactiveMetadata } from '@/utility/page-metadata.js';
 import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
 import type { PageHeaderItem } from '@/types/page-header.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 
 const props = withDefaults(defineProps<{
 	overridePageMetadata?: PageMetadata;
diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue
index db01c10eb0..6080bad9cd 100644
--- a/packages/frontend/src/components/global/MkSpacer.vue
+++ b/packages/frontend/src/components/global/MkSpacer.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { inject } from 'vue';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { deviceKind } from '@/utility/device-kind.js';
 
 const props = withDefaults(defineProps<{
 	contentMax?: number | null;
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 3d2036e376..d28db0d0ca 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue';
 import { toUnicode as decodePunycode } from 'punycode.js';
 import { url as local } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
 
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index c2dc05efe6..f813bcb73f 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script setup lang="ts">
 import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
 import { GridEventEmitter } from '@/components/grid/grid.js';
-import { useTooltip } from '@/scripts/use-tooltip.js';
+import { useTooltip } from '@/utility/use-tooltip.js';
 import * as os from '@/os.js';
 import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
 import type { Size } from '@/components/grid/grid.js';
diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue
index c89e23c135..94f4f3dab1 100644
--- a/packages/frontend/src/components/grid/MkGrid.vue
+++ b/packages/frontend/src/components/grid/MkGrid.vue
@@ -66,7 +66,7 @@ import {
 import * as os from '@/os.js';
 import { createColumn } from '@/components/grid/column.js';
 import { createRow, defaultGridRowSetting, resetRow } from '@/components/grid/row.js';
-import { handleKeyEvent } from '@/scripts/key-event.js';
+import { handleKeyEvent } from '@/utility/key-event.js';
 
 import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js';
 import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts
index 4f48af194c..9e5402354e 100644
--- a/packages/frontend/src/components/grid/grid-utils.ts
+++ b/packages/frontend/src/components/grid/grid-utils.ts
@@ -10,7 +10,7 @@ import { CELL_ADDRESS_NONE } from '@/components/grid/cell.js';
 import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
 import type { GridRow } from '@/components/grid/row.js';
 import type { GridContext } from '@/components/grid/grid-event.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import type { GridColumn, GridColumnSetting } from '@/components/grid/column.js';
 
 export function isCellElement(elem: HTMLElement): boolean {
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
index 84436e7adb..df26874c17 100644
--- a/packages/frontend/src/components/page/page.note.vue
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -15,7 +15,7 @@ import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkNote from '@/components/MkNote.vue';
 import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const props = defineProps<{
 	block: Misskey.entities.PageBlock,
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index e0c7956f6e..7702e250e4 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
-import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 
 const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts
index 0d03282cee..45d4b40fd7 100644
--- a/packages/frontend/src/custom-emojis.ts
+++ b/packages/frontend/src/custom-emojis.ts
@@ -5,8 +5,8 @@
 
 import { shallowRef, computed, markRaw, watch } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { get, set } from '@/scripts/idb-proxy.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
+import { get, set } from '@/utility/idb-proxy.js';
 
 const storageCache = await get('emojis');
 export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []);
diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts
index dee9a3db78..5607b54515 100644
--- a/packages/frontend/src/deck.ts
+++ b/packages/frontend/src/deck.ts
@@ -7,8 +7,8 @@ import { throttle } from 'throttle-debounce';
 import { notificationTypes } from 'misskey-js';
 import type { BasicTimelineType } from '@/timelines.js';
 import type { SoundStore } from '@/preferences/def.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { deepClone } from '@/scripts/clone.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { deepClone } from '@/utility/clone.js';
 import { store } from '@/store.js';
 
 type ColumnWidget = {
diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts
index 98e3d91b29..a68cd1b18b 100644
--- a/packages/frontend/src/directives/adaptive-bg.ts
+++ b/packages/frontend/src/directives/adaptive-bg.ts
@@ -4,7 +4,7 @@
  */
 
 import type { Directive } from 'vue';
-import { getBgColor } from '@/scripts/get-bg-color.js';
+import { getBgColor } from '@/utility/get-bg-color.js';
 
 export default {
 	mounted(src, binding, vn) {
diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts
index 4037076cae..af7deb5960 100644
--- a/packages/frontend/src/directives/adaptive-border.ts
+++ b/packages/frontend/src/directives/adaptive-border.ts
@@ -4,7 +4,7 @@
  */
 
 import type { Directive } from 'vue';
-import { getBgColor } from '@/scripts/get-bg-color.js';
+import { getBgColor } from '@/utility/get-bg-color.js';
 
 export default {
 	mounted(src, binding, vn) {
diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts
index ec00652381..75e022e98f 100644
--- a/packages/frontend/src/directives/hotkey.ts
+++ b/packages/frontend/src/directives/hotkey.ts
@@ -4,7 +4,7 @@
  */
 
 import type { Directive } from 'vue';
-import { makeHotkey } from '@/scripts/hotkey.js';
+import { makeHotkey } from '@/utility/hotkey.js';
 
 export default {
 	mounted(el, binding) {
diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts
index 19fd374861..17916fb6d3 100644
--- a/packages/frontend/src/directives/panel.ts
+++ b/packages/frontend/src/directives/panel.ts
@@ -4,7 +4,7 @@
  */
 
 import type { Directive } from 'vue';
-import { getBgColor } from '@/scripts/get-bg-color.js';
+import { getBgColor } from '@/utility/get-bg-color.js';
 
 export default {
 	mounted(src, binding, vn) {
diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts
index 6bfe6ac31d..068186dfa0 100644
--- a/packages/frontend/src/directives/tooltip.ts
+++ b/packages/frontend/src/directives/tooltip.ts
@@ -8,7 +8,7 @@
 
 import { defineAsyncComponent, ref } from 'vue';
 import type { Directive } from 'vue';
-import { isTouchUsing } from '@/scripts/touch.js';
+import { isTouchUsing } from '@/utility/touch.js';
 import { popup, alert } from '@/os.js';
 
 const start = isTouchUsing ? 'touchstart' : 'mouseenter';
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index 71cb42b30c..c694d49c8a 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -5,7 +5,7 @@
 
 import { computed, reactive } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js';
 
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 096d404a57..837f333c9a 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -4,15 +4,15 @@
  */
 
 import { computed, reactive } from 'vue';
-import { clearCache } from './scripts/clear-cache.js';
+import { clearCache } from './utility/clear-cache.js';
 import { $i } from '@/account.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
-import { lookup } from '@/scripts/lookup.js';
+import { lookup } from '@/utility/lookup.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { ui } from '@@/js/config.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 
 export const navbarItemDef = reactive({
 	notifications: {
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 2142432953..a009a3f3d1 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -10,10 +10,10 @@ import { EventEmitter } from 'eventemitter3';
 import * as Misskey from 'misskey-js';
 import type { Component, Ref } from 'vue';
 import type { ComponentProps as CP } from 'vue-component-type-helpers';
-import type { Form, GetFormResultType } from '@/scripts/form.js';
+import type { Form, GetFormResultType } from '@/utility/form.js';
 import type { MenuItem } from '@/types/menu.js';
 import type { PostFormProps } from '@/types/post-form.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
@@ -25,11 +25,11 @@ import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
 import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
 import MkPopupMenu from '@/components/MkPopupMenu.vue';
 import MkContextMenu from '@/components/MkContextMenu.vue';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
-import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
-import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
-import { focusParent } from '@/scripts/focus.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { pleaseLogin } from '@/utility/please-login.js';
+import { showMovedDialog } from '@/utility/show-moved-dialog.js';
+import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
+import { focusParent } from '@/utility/focus.js';
 
 export const openingWindowsCount = ref(0);
 
diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue
index 9e3a6777f1..05fa8a8a6b 100644
--- a/packages/frontend/src/pages/_error_.vue
+++ b/packages/frontend/src/pages/_error_.vue
@@ -30,10 +30,10 @@ import * as Misskey from 'misskey-js';
 import { version } from '@@/js/config.js';
 import MkButton from '@/components/MkButton.vue';
 import MkLink from '@/components/MkLink.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
 import { serverErrorImageUrl } from '@/instance.js';
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 6e5b52ea8a..d734ea64b1 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -140,13 +140,13 @@ import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
-import { physics } from '@/scripts/physics.js';
+import { physics } from '@/utility/physics.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
 import { $i } from '@/account.js';
 
 const patronsWithIcon = [{
diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue
index e5e57c05c4..0b9eee7d49 100644
--- a/packages/frontend/src/pages/about.overview.vue
+++ b/packages/frontend/src/pages/about.overview.vue
@@ -130,7 +130,7 @@ import { host, version } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import number from '@/filters/number.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import FormSplit from '@/components/form/split.vue';
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index ef0fd39ffe..11a2ed4062 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -27,8 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, defineAsyncComponent, ref, watch } from 'vue';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { claimAchievement } from '@/scripts/achievements.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { claimAchievement } from '@/utility/achievements.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue
index 77ab473ea2..f23046b476 100644
--- a/packages/frontend/src/pages/achievements.vue
+++ b/packages/frontend/src/pages/achievements.vue
@@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
 import MkAchievements from '@/components/MkAchievements.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { $i } from '@/account.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 
 let timer: number | null;
 
diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue
index 7ccb996fff..46a5388e7e 100644
--- a/packages/frontend/src/pages/admin-file.vue
+++ b/packages/frontend/src/pages/admin-file.vue
@@ -83,9 +83,9 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import bytes from '@/filters/bytes.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { iAmAdmin, iAmModerator } from '@/account.js';
 
 const tab = ref('overview');
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 578945bf07..e1da305b84 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -229,9 +229,9 @@ import FormSuspense from '@/components/form/suspense.vue';
 import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { acct } from '@/filters/user.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { iAmAdmin, $i, iAmModerator } from '@/account.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 4762ef3f97..6c47e6397f 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import { rolesCache } from '@/cache.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index 9b1bf51f58..8ba35d65d5 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -39,7 +39,7 @@ import { popupMenu } from '@/os.js';
 import { scrollToTop } from '@@/js/scroll.js';
 import MkButton from '@/components/MkButton.vue';
 import { globalEvents } from '@/events.js';
-import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
+import { injectReactiveMetadata } from '@/utility/page-metadata.js';
 
 type Tab = {
 	key?: string | null;
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
index 5f683c7a1d..5b049e5bad 100644
--- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
@@ -77,7 +77,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
 import MkInput from '@/components/MkInput.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkSelect from '@/components/MkSelect.vue';
 import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
 import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
index f5249261be..ee87fae606 100644
--- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
@@ -49,7 +49,7 @@ import { entities } from 'misskey-js';
 import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
 import XRecipient from './notification-recipient.item.vue';
 import XHeader from '@/pages/admin/_header_.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index c7a45a502e..7df25f48c1 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -65,7 +65,7 @@ import MkSelect from '@/components/MkSelect.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import XAbuseReport from '@/components/MkAbuseReport.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { store } from '@/store.js';
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
index 0d67359e47..d707e15c61 100644
--- a/packages/frontend/src/pages/admin/ads.vue
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -96,9 +96,9 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const ads = ref<Misskey.entities.Ad[]>([]);
 
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
index e420586017..48a072bc8a 100644
--- a/packages/frontend/src/pages/admin/announcements.vue
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -94,9 +94,9 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index 2d314c822d..59b8435595 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -160,10 +160,10 @@ import MkRadios from '@/components/MkRadios.vue';
 import MkInput from '@/components/MkInput.vue';
 import FormSlot from '@/components/form/slot.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { useForm } from '@/scripts/use-form.js';
+import { useForm } from '@/utility/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue
index 95f82c1f24..02f08ecc05 100644
--- a/packages/frontend/src/pages/admin/branding.vue
+++ b/packages/frontend/src/pages/admin/branding.vue
@@ -111,10 +111,10 @@ import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { instance, fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkColorInput from '@/components/MkColorInput.vue';
 import { host } from '@@/js/config.js';
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
index 06d13cda75..260177c894 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -85,9 +85,9 @@ import MkGrid from '@/components/grid/MkGrid.vue';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import { validators } from '@/components/grid/cell-validators.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkPagingButtons from '@/components/MkPagingButtons.vue';
-import { selectFile } from '@/scripts/select-file.js';
+import { selectFile } from '@/utility/select-file.js';
 import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
 import { useLoading } from '@/components/hook/useLoading.js';
 
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
index 6537b844c3..666e3c95ac 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
@@ -80,10 +80,10 @@ import * as Misskey from 'misskey-js';
 import { onMounted, ref, useCssModule } from 'vue';
 import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
 import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
-import type { DroppedFile } from '@/scripts/file-drop.js';
+import type { DroppedFile } from '@/utility/file-drop.js';
 import type { GridSetting } from '@/components/grid/grid.js';
 import type { GridRow } from '@/components/grid/row.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import {
 	emptyStrToEmptyArray,
 	emptyStrToNull,
@@ -97,9 +97,9 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { validators } from '@/components/grid/cell-validators.js';
-import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
-import { uploadFile } from '@/scripts/upload.js';
-import { extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
+import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js';
+import { uploadFile } from '@/utility/upload.js';
+import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
 import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
 
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
index 609d445d79..c868a700f1 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
@@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onMounted, ref, useCssModule } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -152,7 +152,7 @@ import { emptyStrToUndefined, gridSortOrderKeys } from '@/pages/admin/custom-emo
 import MkFolder from '@/components/MkFolder.vue';
 import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import * as os from '@/os.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import MkPagingButtons from '@/components/MkPagingButtons.vue';
 import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
 import { useLoading } from '@/components/hook/useLoading.js';
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
index fb930064ff..f75393baac 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script setup lang="ts">
 import { computed, ref } from 'vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue';
 import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue';
 import MkPageHeader from '@/components/global/MkPageHeader.vue';
diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue
index e092efd92c..807a4cc401 100644
--- a/packages/frontend/src/pages/admin/database.vue
+++ b/packages/frontend/src/pages/admin/database.vue
@@ -21,11 +21,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import bytes from '@/filters/bytes.js';
 import number from '@/filters/number.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const databasePromiseFactory = () => misskeyApi('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
 
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
index 5b60e67dac..930c174121 100644
--- a/packages/frontend/src/pages/admin/email-settings.vue
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -73,10 +73,10 @@ 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.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance, instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 
 const enableEmail = ref<boolean>(false);
diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue
index a312ecce12..559b73db16 100644
--- a/packages/frontend/src/pages/admin/external-services.vue
+++ b/packages/frontend/src/pages/admin/external-services.vue
@@ -49,10 +49,10 @@ import MkButton from '@/components/MkButton.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkFolder from '@/components/MkFolder.vue';
 
 const deeplAuthKey = ref<string>('');
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index e7b9fd8621..7fef797c62 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -67,7 +67,7 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import FormSplit from '@/components/form/split.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const host = ref('');
 const state = ref('federating');
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
index 4cc859227f..f9955e8d13 100644
--- a/packages/frontend/src/pages/admin/files.vue
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -42,9 +42,9 @@ import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
 import * as os from '@/os.js';
-import { lookupFile } from '@/scripts/admin-lookup.js';
+import { lookupFile } from '@/utility/admin-lookup.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const origin = ref('local');
 const type = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 672ee8da18..26159a97d7 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -37,12 +37,12 @@ import MkSuperMenu from '@/components/MkSuperMenu.vue';
 import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { instance } from '@/instance.js';
-import { lookup } from '@/scripts/lookup.js';
+import { lookup } from '@/utility/lookup.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js';
-import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js';
+import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import { useRouter } from '@/router/supplier.js';
 
 const isEmpty = (x: string | null) => x == null || x === '';
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 5189e12899..80bb101e67 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -59,7 +59,7 @@ import { computed, ref, shallowRef } from 'vue';
 import XHeader from './_header_.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSelect from '@/components/MkSelect.vue';
@@ -68,7 +68,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
 
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index ac1fe7783c..001af0e059 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -134,10 +134,10 @@ import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import FormLink from '@/components/form/link.vue';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue
index c9eaf07531..9bde2b5978 100644
--- a/packages/frontend/src/pages/admin/modlog.vue
+++ b/packages/frontend/src/pages/admin/modlog.vue
@@ -38,7 +38,7 @@ import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 
 const logs = shallowRef<InstanceType<typeof MkPagination>>();
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
index d5a664934c..6347673704 100644
--- a/packages/frontend/src/pages/admin/object-storage.vue
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -90,10 +90,10 @@ import MkInput from '@/components/MkInput.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 
 const useObjectStorage = ref<boolean>(false);
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 1a72f9050c..bb7eef36ad 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -16,11 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index 197f792755..dd5d4b0e72 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -24,12 +24,12 @@ import { onMounted, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import isChromatic from 'chromatic';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
 import { store } from '@/store.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue
index 7d80f8c2e3..362f0974e0 100644
--- a/packages/frontend/src/pages/admin/overview.federation.vue
+++ b/packages/frontend/src/pages/admin/overview.federation.vue
@@ -50,11 +50,11 @@ import { onMounted, ref } from 'vue';
 import XPie from './overview.pie.vue';
 import type { InstanceForPie } from './overview.pie.vue';
 import * as os from '@/os.js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import number from '@/filters/number.js';
 import MkNumberDiff from '@/components/MkNumberDiff.vue';
 import { i18n } from '@/i18n.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
 
 const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
 const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
index 67c0297dec..c8291459fe 100644
--- a/packages/frontend/src/pages/admin/overview.instances.vue
+++ b/packages/frontend/src/pages/admin/overview.instances.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue
index 4d503c0035..fb2c5ea13c 100644
--- a/packages/frontend/src/pages/admin/overview.moderators.vue
+++ b/packages/frontend/src/pages/admin/overview.moderators.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { prefer } from '@/preferences.js';
 
 const moderators = ref<Misskey.entities.UserDetailed[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index a21ec6c464..792565dd96 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { initChart } from '@/utility/init-chart.js';
 
 export type InstanceForPie = {
 	name: string,
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 7b83641fbd..6f461b838d 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue
index 72b93ee97c..fd8145b308 100644
--- a/packages/frontend/src/pages/admin/overview.stats.vue
+++ b/packages/frontend/src/pages/admin/overview.stats.vue
@@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import MkNumberDiff from '@/components/MkNumberDiff.vue';
 import MkNumber from '@/components/MkNumber.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue
index 8de4f298ce..6a39f4561f 100644
--- a/packages/frontend/src/pages/admin/overview.users.vue
+++ b/packages/frontend/src/pages/admin/overview.users.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
index 1de4dc0dc8..3c0f3d0769 100644
--- a/packages/frontend/src/pages/admin/overview.vue
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -79,10 +79,10 @@ import XModerators from './overview.moderators.vue';
 import XHeatmap from './overview.heatmap.vue';
 import type { InstanceForPie } from './overview.pie.vue';
 import * as os from '@/os.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 
 const rootEl = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue
index 12338f0bf9..cf9448a813 100644
--- a/packages/frontend/src/pages/admin/performance.vue
+++ b/packages/frontend/src/pages/admin/performance.vue
@@ -111,15 +111,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed } from 'vue';
 import XHeader from './_header_.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkLink from '@/components/MkLink.vue';
-import { useForm } from '@/scripts/use-form.js';
+import { useForm } from '@/utility/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index debb48a15a..77d3332523 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { alpha } from '@/scripts/color.js';
-import { initChart } from '@/scripts/init-chart.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { alpha } from '@/utility/color.js';
+import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
index 7c171ba0e1..607a974d20 100644
--- a/packages/frontend/src/pages/admin/queue.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -53,7 +53,7 @@ import * as Misskey from 'misskey-js';
 import XChart from './queue.chart.chart.vue';
 import type { ApQueueDomain } from '@/pages/admin/queue.vue';
 import number from '@/filters/number.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue
index 1a26c00ddb..e5b62e566b 100644
--- a/packages/frontend/src/pages/admin/queue.vue
+++ b/packages/frontend/src/pages/admin/queue.vue
@@ -23,7 +23,7 @@ import XHeader from './_header_.vue';
 import * as os from '@/os.js';
 import * as config from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 
 export type ApQueueDomain = 'deliver' | 'inbox';
diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue
index 17e99e6593..8a6a46934d 100644
--- a/packages/frontend/src/pages/admin/relays.vue
+++ b/packages/frontend/src/pages/admin/relays.vue
@@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js';
 import XHeader from './_header_.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const relays = ref<Misskey.entities.AdminRelaysListResponse>([]);
 
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index 2b4006c3f7..87aff1e1ef 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -28,9 +28,9 @@ import { v4 as uuid } from 'uuid';
 import XHeader from './_header_.vue';
 import XEditor from './roles.editor.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import { rolesCache } from '@/cache.js';
 import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index d05f52334e..4e9f4edb70 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -710,7 +710,7 @@ import MkRange from '@/components/MkRange.vue';
 import FormSlot from '@/components/form/slot.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 
 const emit = defineEmits<{
 	(ev: 'update:modelValue', v: any): void;
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 1c237a69b4..4efcab37cb 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -67,9 +67,9 @@ import XHeader from './_header_.vue';
 import XEditor from './roles.editor.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkInfo from '@/components/MkInfo.vue';
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 782c1256de..1b183d67c8 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -290,9 +290,9 @@ import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { instance, fetchInstance } from '@/instance.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index 975a4a1265..3eaf0fb358 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -131,11 +131,11 @@ import MkRange from '@/components/MkRange.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { useForm } from '@/scripts/use-form.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { useForm } from '@/utility/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue
index cd50f92143..314a2be517 100644
--- a/packages/frontend/src/pages/admin/server-rules.vue
+++ b/packages/frontend/src/pages/admin/server-rules.vue
@@ -46,7 +46,7 @@ import XHeader from './_header_.vue';
 import * as os from '@/os.js';
 import { fetchInstance, instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index aed593fc54..06b52f11b9 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -266,14 +266,14 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance, instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import { useForm } from '@/scripts/use-form.js';
+import { useForm } from '@/utility/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkRadios from '@/components/MkRadios.vue';
 
diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue
index c59abda24a..26db61cb0b 100644
--- a/packages/frontend/src/pages/admin/system-webhook.vue
+++ b/packages/frontend/src/pages/admin/system-webhook.vue
@@ -30,11 +30,11 @@ import { computed, onMounted, ref } from 'vue';
 import { entities } from 'misskey-js';
 import XItem from './system-webhook.item.vue';
 import FormSection from '@/components/form/section.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import XHeader from '@/pages/admin/_header_.vue';
 import MkButton from '@/components/MkButton.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
 import * as os from '@/os.js';
 
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 91104b676d..3f722e2dc8 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -68,9 +68,9 @@ import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import * as os from '@/os.js';
-import { lookupUser } from '@/scripts/admin-lookup.js';
+import { lookupUser } from '@/utility/admin-lookup.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import { dateString } from '@/filters/date.js';
 
diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue
index b31807f9f5..81aee08f70 100644
--- a/packages/frontend/src/pages/ads.vue
+++ b/packages/frontend/src/pages/ads.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 
diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue
index b8903a8ea6..c47074165d 100644
--- a/packages/frontend/src/pages/announcement.vue
+++ b/packages/frontend/src/pages/announcement.vue
@@ -52,9 +52,9 @@ import { ref, computed, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { $i, updateAccountPartial } from '@/account.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index 75c0fd98dc..28d9866a69 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -53,9 +53,9 @@ import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { $i, updateAccountPartial } from '@/account.js';
 
 const paginationCurrent = {
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index a01bafd996..b1fa874f65 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -29,8 +29,8 @@ import * as Misskey from 'misskey-js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue
index 9bddb0a9d2..b6942d0b9a 100644
--- a/packages/frontend/src/pages/api-console.vue
+++ b/packages/frontend/src/pages/api-console.vue
@@ -41,8 +41,8 @@ import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const body = ref('{}');
 const endpoint = ref('');
diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue
index f4fb2ef4d5..1917293c06 100644
--- a/packages/frontend/src/pages/auth.form.vue
+++ b/packages/frontend/src/pages/auth.form.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkButton from '@/components/MkButton.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue
index 4170b4f73e..ef6be9b9ee 100644
--- a/packages/frontend/src/pages/auth.vue
+++ b/packages/frontend/src/pages/auth.vue
@@ -46,9 +46,9 @@ import { onMounted, ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import XForm from './auth.form.vue';
 import MkSignin from '@/components/MkSignin.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { $i, login } from '@/account.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
index a834f1c5fd..a3c5a36614 100644
--- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
+++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
@@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkRolePreview from '@/components/MkRolePreview.vue';
diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue
index a5cafb1678..2858be9fd7 100644
--- a/packages/frontend/src/pages/avatar-decorations.vue
+++ b/packages/frontend/src/pages/avatar-decorations.vue
@@ -30,9 +30,9 @@ import { ref, computed, defineAsyncComponent } from 'vue';
 import * as Misskey from 'misskey-js';
 import { signinRequired } from '@/account.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
index 6d8274a55c..b695008088 100644
--- a/packages/frontend/src/pages/channel-editor.vue
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -74,10 +74,10 @@ import * as Misskey from 'misskey-js';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkColorInput from '@/components/MkColorInput.vue';
-import { selectFile } from '@/scripts/select-file.js';
+import { selectFile } from '@/utility/select-file.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index d333204ff8..4a841329e0 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -81,11 +81,11 @@ import MkPostForm from '@/components/MkPostForm.vue';
 import MkTimeline from '@/components/MkTimeline.vue';
 import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { $i, iAmModerator } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import MkNotes from '@/components/MkNotes.vue';
 import { favoritedChannelsCache } from '@/cache.js';
 import MkButton from '@/components/MkButton.vue';
@@ -95,9 +95,9 @@ import MkNote from '@/components/MkNote.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { notesSearchAvailable } from '@/scripts/check-permissions.js';
+import { isSupportShare } from '@/utility/navigator.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { notesSearchAvailable } from '@/utility/check-permissions.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index 6830c1ace4..d21d2a9c53 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -69,7 +69,7 @@ import MkRadios from '@/components/MkRadios.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue
index 9e9b5e8688..b0adb98354 100644
--- a/packages/frontend/src/pages/clicker.vue
+++ b/packages/frontend/src/pages/clicker.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import MkClickerGame from '@/components/MkClickerGame.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 definePageMetadata(() => ({
 	title: '🍪👈',
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index 224bc51599..5a89151775 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -39,13 +39,13 @@ import MkNotes from '@/components/MkNotes.vue';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import { clipsCache } from '@/cache.js';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
+import { isSupportShare } from '@/utility/navigator.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { assertServerContext, serverContext } from '@/server-context.js';
 
 // contextは非ログイン状態の情報しかないためログイン時は利用できない
diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue
index 1f2bee5a77..46bf551da5 100644
--- a/packages/frontend/src/pages/contact.vue
+++ b/packages/frontend/src/pages/contact.vue
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkLink from '@/components/MkLink.vue';
 
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 82c6d8df4e..450e7ef74b 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -81,12 +81,12 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import FormSplit from '@/components/form/split.vue';
-import { selectFile } from '@/scripts/select-file.js';
+import { selectFile } from '@/utility/select-file.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
 
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index dfcc82c77b..3f78935b0a 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -85,7 +85,7 @@ import bytes from '@/filters/bytes.js';
 import { infoImageUrl } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue
index 5711ec8b3a..1a9f13daf4 100644
--- a/packages/frontend/src/pages/drive.file.vue
+++ b/packages/frontend/src/pages/drive.file.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, defineAsyncComponent } from 'vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue
index 25e140f67f..466fedb479 100644
--- a/packages/frontend/src/pages/drive.vue
+++ b/packages/frontend/src/pages/drive.vue
@@ -14,7 +14,7 @@ import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XDrive from '@/components/MkDrive.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const folder = ref<Misskey.entities.DriveFolder | null>(null);
 
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index 55c984e1bf..3478a11ad4 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -198,20 +198,20 @@ import { DropAndFusionGame } from 'misskey-bubble-game';
 import { useInterval } from '@@/js/use-interval.js';
 import { apiUrl } from '@@/js/config.js';
 import type { Mono } from 'misskey-bubble-game';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import * as os from '@/os.js';
 import MkNumber from '@/components/MkNumber.vue';
 import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
 import MkButton from '@/components/MkButton.vue';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { store } from '@/store.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 import MkRange from '@/components/MkRange.vue';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { prefer } from '@/preferences.js';
 
 type FrontendMonoDefinition = {
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index 54352c9b0d..47b04229fc 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -88,12 +88,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, watch } from 'vue';
 import XGame from './drop-and-fusion.game.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 
 const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal');
 const gameStarted = ref(false);
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index da3315cff5..581198c89d 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -87,11 +87,11 @@ import MkInput from '@/components/MkInput.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { customEmojiCategories } from '@/custom-emojis.js';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { selectFile } from '@/scripts/select-file.js';
+import { selectFile } from '@/utility/select-file.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue
index 979d50966e..35a240b9ba 100644
--- a/packages/frontend/src/pages/emojis.emoji.vue
+++ b/packages/frontend/src/pages/emojis.emoji.vue
@@ -18,8 +18,8 @@ import * as Misskey from 'misskey-js';
 import { defineAsyncComponent } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
 import { $i } from '@/account.js';
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
index 389cd23ad2..ffefeb9618 100644
--- a/packages/frontend/src/pages/explore.roles.vue
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const roles = ref<Misskey.entities.Role[] | null>(null);
 
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index 56ae08b322..c112045a21 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -68,7 +68,7 @@ import * as Misskey from 'misskey-js';
 import MkUserList from '@/components/MkUserList.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkTab from '@/components/MkTab.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index b1a8183d9b..878c7be4d9 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -27,7 +27,7 @@ import XUsers from './explore.users.vue';
 import XRoles from './explore.roles.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue
index 6716566101..aec45d0045 100644
--- a/packages/frontend/src/pages/favorites.vue
+++ b/packages/frontend/src/pages/favorites.vue
@@ -30,7 +30,7 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkNote from '@/components/MkNote.vue';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { infoImageUrl } from '@/instance.js';
 
 const pagination = {
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index d84ec4873b..d01acc3787 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -46,9 +46,9 @@ import * as Misskey from 'misskey-js';
 import { AISCRIPT_VERSION } from '@syuilo/aiscript';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkInput from '@/components/MkInput.vue';
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index 2b85489706..688b5c2ac6 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -46,7 +46,7 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 532ec87dac..e2e7cc6f74 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -67,23 +67,23 @@ import * as Misskey from 'misskey-js';
 import { Interpreter, Parser, values } from '@syuilo/aiscript';
 import { url } from '@@/js/config.js';
 import type { Ref } from 'vue';
-import type { AsUiComponent, AsUiRoot } from '@/scripts/aiscript/ui.js';
+import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
 import type { MenuItem } from '@/types/menu.js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkAsUi from '@/components/MkAsUi.vue';
-import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { registerAsUiLib } from '@/aiscript/ui.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkCode from '@/components/MkCode.vue';
 import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
+import { isSupportShare } from '@/utility/navigator.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { pleaseLogin } from '@/utility/please-login.js';
 
 const props = defineProps<{
 	id: string;
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index 863ae018ba..43532026fc 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -52,7 +52,7 @@ import MkButton from '@/components/MkButton.vue';
 import { userPage, acct } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/account.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
index 70f8b2c31d..974dd18224 100644
--- a/packages/frontend/src/pages/gallery/edit.vue
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -45,10 +45,10 @@ import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import FormSuspense from '@/components/form/suspense.vue';
-import { selectFiles } from '@/scripts/select-file.js';
+import { selectFiles } from '@/utility/select-file.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue
index f396fd2c0c..49b7e349e8 100644
--- a/packages/frontend/src/pages/gallery/index.vue
+++ b/packages/frontend/src/pages/gallery/index.vue
@@ -52,7 +52,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index 7e1b7be16c..81c63b0f4e 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -69,17 +69,17 @@ import { url } from '@@/js/config.js';
 import type { MenuItem } from '@/types/menu.js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
 import MkFollowButton from '@/components/MkFollowButton.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { prefer } from '@/preferences.js';
 import { $i } from '@/account.js';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { isSupportShare } from '@/utility/navigator.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index 998b8be0f3..f6eb8a5d7e 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 definePageMetadata(() => ({
 	title: 'Misskey Games',
diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue
index 423bd611e6..055ee8a609 100644
--- a/packages/frontend/src/pages/install-extensions.vue
+++ b/packages/frontend/src/pages/install-extensions.vue
@@ -54,12 +54,12 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkUrl from '@/components/global/MkUrl.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { parsePluginMeta, installPlugin } from '@/plugin.js';
-import { parseThemeCode, installTheme } from '@/scripts/theme.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { parseThemeCode, installTheme } from '@/utility/theme.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching');
 const errorKV = ref<{
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 35d1c7dcd1..78ecd180cc 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -146,16 +146,16 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import number from '@/filters/number.js';
 import { iAmModerator, iAmAdmin } from '@/account.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 import { dateString } from '@/filters/date.js';
 import MkTextarea from '@/components/MkTextarea.vue';
 
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index 49c0d931c5..fbe54e9951 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -38,12 +38,12 @@ import { computed, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkButton from '@/components/MkButton.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { serverErrorImageUrl, instance } from '@/instance.js';
 import { $i } from '@/account.js';
 
diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue
index 0ff1854154..f1b3f7a22f 100644
--- a/packages/frontend/src/pages/list.vue
+++ b/packages/frontend/src/pages/list.vue
@@ -37,12 +37,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { watch, computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkButton from '@/components/MkButton.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { serverErrorImageUrl } from '@/instance.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue
index 6f10c69640..ff072ba527 100644
--- a/packages/frontend/src/pages/lookup.vue
+++ b/packages/frontend/src/pages/lookup.vue
@@ -22,9 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { mainRouter } from '@/router/main.js';
 import MkButton from '@/components/MkButton.vue';
 
diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue
index ab060587c5..3060c97a49 100644
--- a/packages/frontend/src/pages/miauth.vue
+++ b/packages/frontend/src/pages/miauth.vue
@@ -36,8 +36,8 @@ import MkAnimBg from '@/components/MkAnimBg.vue';
 import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
 
 import { i18n } from '@/i18n.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const props = defineProps<{
 	session: string;
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index 2b8518747f..2f87de76fe 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { antennasCache } from '@/cache.js';
 import { useRouter } from '@/router/supplier.js';
 import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
index 9f927cd1a0..ed9afd49ae 100644
--- a/packages/frontend/src/pages/my-antennas/edit.vue
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { antennasCache } from '@/cache.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue
index f387740728..0e9a709a99 100644
--- a/packages/frontend/src/pages/my-antennas/index.vue
+++ b/packages/frontend/src/pages/my-antennas/index.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onActivated, computed } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { antennasCache } from '@/cache.js';
 import { infoImageUrl } from '@/instance.js';
 
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index acf37a9a2f..2fba8d3bc9 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -30,9 +30,9 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkClipPreview from '@/components/MkClipPreview.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { clipsCache } from '@/cache.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
index 6cbcca73c2..5449aded15 100644
--- a/packages/frontend/src/pages/my-lists/index.vue
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -34,7 +34,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkAvatars from '@/components/MkAvatars.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { userListsCache } from '@/cache.js';
 import { infoImageUrl } from '@/instance.js';
 import { signinRequired } from '@/account.js';
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index acedeeeb7d..5160124994 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -57,8 +57,8 @@ import { computed, ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { userPage } from '@/filters/user.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue
index 6a2d01b6fa..4605d67b49 100644
--- a/packages/frontend/src/pages/not-found.vue
+++ b/packages/frontend/src/pages/not-found.vue
@@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { pleaseLogin } from '@/utility/please-login.js';
 import { notFoundImageUrl } from '@/instance.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index eaeef2e566..2292072817 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -56,14 +56,14 @@ import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
 import MkNotes from '@/components/MkNotes.vue';
 import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
 import MkButton from '@/components/MkButton.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { dateString } from '@/filters/date.js';
 import MkClipPreview from '@/components/MkClipPreview.vue';
 import { prefer } from '@/preferences.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
-import { getAppearNote } from '@/scripts/get-appear-note.js';
+import { pleaseLogin } from '@/utility/please-login.js';
+import { getAppearNote } from '@/utility/get-appear-note.js';
 import { serverContext, assertServerContext } from '@/server-context.js';
 import { $i } from '@/account.js';
 
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
index 46ee501c76..9887c39e6b 100644
--- a/packages/frontend/src/pages/notifications.vue
+++ b/packages/frontend/src/pages/notifications.vue
@@ -29,7 +29,7 @@ import MkNotes from '@/components/MkNotes.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { notificationTypes } from '@@/js/const.js';
 
 const tab = ref('all');
diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue
index 860c884d13..1346c696b9 100644
--- a/packages/frontend/src/pages/oauth.vue
+++ b/packages/frontend/src/pages/oauth.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
 import MkAnimBg from '@/components/MkAnimBg.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
 
 const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
index c3ad6657b0..1b98425719 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
@@ -26,7 +26,7 @@ import * as Misskey from 'misskey-js';
 import XContainer from '../page-editor.container.vue';
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
index 36e03b4790..f275ec9517 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
@@ -30,7 +30,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkNote from '@/components/MkNote.vue';
 import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
index 3fed07f7e8..4d1a3716e7 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
@@ -21,14 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
- 
+
 import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { v4 as uuid } from 'uuid';
 import XContainer from '../page-editor.container.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import MkButton from '@/components/MkButton.vue';
 import { getPageBlockList } from '@/pages/page-editor/common.js';
 
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
index 5795b46c00..a8b4da2a06 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
- 
+
 import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import XContainer from '../page-editor.container.vue';
 import { i18n } from '@/i18n.js';
-import { Autocomplete } from '@/scripts/autocomplete.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
 
 const props = defineProps<{
 	modelValue: Misskey.entities.PageBlock & { type: 'text' }
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index c08cfebab3..5750b77d20 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -71,10 +71,10 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkInput from '@/components/MkInput.vue';
 import { url } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { selectFile } from '@/scripts/select-file.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { selectFile } from '@/utility/select-file.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { $i } from '@/account.js';
 import { mainRouter } from '@/router/main.js';
 import { getPageBlockList } from '@/pages/page-editor/common.js';
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 3444ef49aa..420b83035b 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -105,7 +105,7 @@ import type { MenuItem } from '@/types/menu.js';
 import XPage from '@/components/page/page.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkMediaImage from '@/components/MkMediaImage.vue';
 import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
 import MkFollowButton from '@/components/MkFollowButton.vue';
@@ -113,14 +113,14 @@ import MkContainer from '@/components/MkContainer.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkPagePreview from '@/components/MkPagePreview.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { pageViewInterruptors } from '@/store.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import { $i } from '@/account.js';
-import { isSupportShare } from '@/scripts/navigator.js';
+import { isSupportShare } from '@/utility/navigator.js';
 import { instance } from '@/instance.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue
index 4ef9d3b091..83b922e5b5 100644
--- a/packages/frontend/src/pages/pages.vue
+++ b/packages/frontend/src/pages/pages.vue
@@ -44,7 +44,7 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue
index 8e07b190aa..52b41f3153 100644
--- a/packages/frontend/src/pages/preview.vue
+++ b/packages/frontend/src/pages/preview.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import MkSample from '@/components/MkPreview.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const headerActions = computed(() => []);
 
diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue
index 4cacbd0906..3bde026763 100644
--- a/packages/frontend/src/pages/registry.keys.vue
+++ b/packages/frontend/src/pages/registry.keys.vue
@@ -36,9 +36,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { watch, computed, ref } from 'vue';
 import JSON5 from 'json5';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/MkButton.vue';
diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue
index c40d13f664..393e44beb3 100644
--- a/packages/frontend/src/pages/registry.value.vue
+++ b/packages/frontend/src/pages/registry.value.vue
@@ -48,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { watch, computed, ref } from 'vue';
 import JSON5 from 'json5';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkButton from '@/components/MkButton.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue
index c641874b17..b251365100 100644
--- a/packages/frontend/src/pages/registry.vue
+++ b/packages/frontend/src/pages/registry.vue
@@ -26,9 +26,9 @@ import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import JSON5 from 'json5';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/MkButton.vue';
diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue
index 6d24029535..12226da272 100644
--- a/packages/frontend/src/pages/reset-password.vue
+++ b/packages/frontend/src/pages/reset-password.vue
@@ -25,7 +25,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { mainRouter } from '@/router/main.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 429f502133..71dd220cfe 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -148,16 +148,16 @@ import * as Reversi from 'misskey-reversi';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import { useInterval } from '@@/js/use-interval.js';
 import { signinRequired } from '@/account.js';
 import { url } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { userPage } from '@/filters/user.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 import * as os from '@/os.js';
-import { confetti } from '@/scripts/confetti.js';
+import { confetti } from '@/utility/confetti.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
index 437a1a2294..03b75f89ae 100644
--- a/packages/frontend/src/pages/reversi/game.setting.vue
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -115,7 +115,7 @@ import * as Misskey from 'misskey-js';
 import * as Reversi from 'misskey-reversi';
 import { i18n } from '@/i18n.js';
 import { signinRequired } from '@/account.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import MkButton from '@/components/MkButton.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index 10ea3717ab..934aa69c6a 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -14,8 +14,8 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import GameSetting from './game.setting.vue';
 import GameBoard from './game.board.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { useStream } from '@/stream.js';
 import { signinRequired } from '@/account.js';
 import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
index d608a2411c..4000ce13c7 100644
--- a/packages/frontend/src/pages/reversi/index.vue
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -107,8 +107,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onDeactivated, onMounted, onUnmounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { useStream } from '@/stream.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
@@ -118,8 +118,8 @@ import MkPagination from '@/components/MkPagination.vue';
 import { useRouter } from '@/router/supplier.js';
 import * as os from '@/os.js';
 import { useInterval } from '@@/js/use-interval.js';
-import { pleaseLogin } from '@/scripts/please-login.js';
-import * as sound from '@/scripts/sound.js';
+import { pleaseLogin } from '@/utility/please-login.js';
+import * as sound from '@/utility/sound.js';
 
 const myGamesPagination = {
 	endpoint: 'reversi/games' as const,
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index 46e510b49b..19d26a7316 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -38,9 +38,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkUserList from '@/components/MkUserList.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import { instanceName } from '@@/js/config.js';
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index 22c06bfc85..357e6621ac 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -64,18 +64,18 @@ import MkContainer from '@/components/MkContainer.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import * as os from '@/os.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
-import type { AsUiComponent } from '@/scripts/aiscript/ui.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { registerAsUiLib } from '@/aiscript/ui.js';
+import type { AsUiComponent } from '@/aiscript/ui.js';
 import MkAsUi from '@/components/MkAsUi.vue';
 import { miLocalStorage } from '@/local-storage.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 
-import type { AsUiRoot } from '@/scripts/aiscript/ui.js';
+import type { AsUiRoot } from '@/aiscript/ui.js';
 
 const parser = new Parser();
 let aiscript: Interpreter;
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index 32a35f8110..bfe56e793b 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -119,8 +119,8 @@ import { host as localHost } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { apLookup } from '@/scripts/lookup.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { apLookup } from '@/utility/lookup.js';
 import { useRouter } from '@/router/supplier.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index 2b8faf5465..40019cc870 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -36,7 +36,7 @@ import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useRouter } from '@/router/supplier.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index 38d7548fa8..e3d1d427a8 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -27,8 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, defineAsyncComponent, ref, toRef } from 'vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { notesSearchAvailable } from '@/scripts/check-permissions.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { notesSearchAvailable } from '@/utility/check-permissions.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index 18c82ffdf6..9093ffd7a9 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -116,7 +116,7 @@ import * as os from '@/os.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkLink from '@/components/MkLink.vue';
-import { confetti } from '@/scripts/confetti.js';
+import { confetti } from '@/utility/confetti.js';
 import { signinRequired } from '@/account.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue
index f464f728ff..c79fb40ae1 100644
--- a/packages/frontend/src/pages/settings/accessibility.vue
+++ b/packages/frontend/src/pages/settings/accessibility.vue
@@ -75,9 +75,9 @@ import { computed, ref, watch } from 'vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import { prefer } from '@/preferences.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 
 const reduceAnimation = prefer.model('animation', v => !v, v => !v);
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
index 4a7301f405..d4a1ffd708 100644
--- a/packages/frontend/src/pages/settings/accounts.vue
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -33,10 +33,10 @@ import * as Misskey from 'misskey-js';
 import FormSuspense from '@/components/form/suspense.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import type { MenuItem } from '@/types/menu.js';
 
diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue
index b35d406a98..b8bb2bb32a 100644
--- a/packages/frontend/src/pages/settings/api.vue
+++ b/packages/frontend/src/pages/settings/api.vue
@@ -16,9 +16,9 @@ import { defineAsyncComponent, ref, computed } from 'vue';
 import FormLink from '@/components/form/link.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const isDesktop = ref(window.innerWidth >= 1100);
 
diff --git a/packages/frontend/src/pages/settings/appearance.vue b/packages/frontend/src/pages/settings/appearance.vue
index b23f32aec4..2ecbf535ba 100644
--- a/packages/frontend/src/pages/settings/appearance.vue
+++ b/packages/frontend/src/pages/settings/appearance.vue
@@ -216,13 +216,13 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import { prefer } from '@/preferences.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
 import FormLink from '@/components/form/link.vue';
 import { globalEvents } from '@/events.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
 import { instance } from '@/instance.js';
diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue
index 6515503505..1962623c3e 100644
--- a/packages/frontend/src/pages/settings/apps.vue
+++ b/packages/frontend/src/pages/settings/apps.vue
@@ -57,9 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import FormPagination from '@/components/MkPagination.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue
index 79be2b9b1e..dbc0d3697d 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.vue
@@ -52,11 +52,11 @@ import * as Misskey from 'misskey-js';
 import XDecoration from './avatar-decoration.decoration.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { signinRequired } from '@/account.js';
 import MkInfo from '@/components/MkInfo.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue
index cf05e75acc..4cdc0d957d 100644
--- a/packages/frontend/src/pages/settings/custom-css.vue
+++ b/packages/frontend/src/pages/settings/custom-css.vue
@@ -18,9 +18,9 @@ import { ref, watch, computed } from 'vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import FormInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
 
 const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? '');
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
index e1965b6d36..6186c493f9 100644
--- a/packages/frontend/src/pages/settings/deck.vue
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -24,7 +24,7 @@ import { computed } from 'vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { prefer } from '@/preferences.js';
 
 const navWindow = prefer.model('deck.navWindow');
diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue
index c5657fce68..3c50677746 100644
--- a/packages/frontend/src/pages/settings/drive-cleaner.vue
+++ b/packages/frontend/src/pages/settings/drive-cleaner.vue
@@ -52,14 +52,14 @@ import { computed, ref, watch } from 'vue';
 import type { StyleValue } from 'vue';
 import tinycolor from 'tinycolor2';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkPagination from '@/components/MkPagination.vue';
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import { i18n } from '@/i18n.js';
 import bytes from '@/filters/bytes.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkSelect from '@/components/MkSelect.vue';
-import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
+import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
 
 const sortMode = ref('+size');
 const pagination = {
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 8e61b11dbe..aab04ebc9c 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -95,11 +95,11 @@ import FormSection from '@/components/form/section.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import bytes from '@/filters/bytes.js';
 import MkChart from '@/components/MkChart.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { signinRequired } from '@/account.js';
 import { prefer } from '@/preferences.js';
 import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index e7a8fc5634..1ca2e8f085 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -66,10 +66,10 @@ import MkInput from '@/components/MkInput.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkDisableSection from '@/components/MkDisableSection.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { signinRequired } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { instance } from '@/instance.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue
index d2060a9112..c453952613 100644
--- a/packages/frontend/src/pages/settings/emoji-picker.vue
+++ b/packages/frontend/src/pages/settings/emoji-picker.vue
@@ -144,10 +144,10 @@ import MkSelect from '@/components/MkSelect.vue';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { deepClone } from '@/scripts/clone.js';
-import { reactionPicker } from '@/scripts/reaction-picker.js';
-import { emojiPicker } from '@/scripts/emoji-picker.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { deepClone } from '@/utility/clone.js';
+import { reactionPicker } from '@/utility/reaction-picker.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
 import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
 import MkEmoji from '@/components/global/MkEmoji.vue';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index 949f9019d9..43fb20bb3c 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -150,10 +150,10 @@ import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { selectFile } from '@/scripts/select-file.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { selectFile } from '@/utility/select-file.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { $i } from '@/account.js';
 import { store } from '@/store.js';
 
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 6203e7f698..9a87f404e7 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -33,18 +33,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script setup lang="ts">
 import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import { i18n } from '@/i18n.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkSuperMenu from '@/components/MkSuperMenu.vue';
 import { signout, $i } from '@/account.js';
-import { clearCache } from '@/scripts/clear-cache.js';
+import { clearCache } from '@/utility/clear-cache.js';
 import { instance } from '@/instance.js';
-import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
 import * as os from '@/os.js';
 import { useRouter } from '@/router/supplier.js';
-import { searchIndexes } from '@/scripts/autogen/settings-search-index.js';
+import { searchIndexes } from '@/utility/autogen/settings-search-index.js';
 import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js';
 import { store } from '@/store.js';
 
diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue
index 1c00d64d73..60386da545 100644
--- a/packages/frontend/src/pages/settings/migration.vue
+++ b/packages/frontend/src/pages/settings/migration.vue
@@ -66,10 +66,10 @@ import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkUserInfo from '@/components/MkUserInfo.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { signinRequired } from '@/account.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
index d1fde2fc1c..52e1937663 100644
--- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
+++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
@@ -20,7 +20,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkButton from '@/components/MkButton.vue';
 import { signinRequired } from '@/account.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index 3620c05ca8..6fa2b5e689 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -178,7 +178,7 @@ import XWordMute from './mute-block.word-mute.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import * as os from '@/os.js';
 import { instance, infoImageUrl } from '@/instance.js';
@@ -186,7 +186,7 @@ import { signinRequired } from '@/account.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { prefer } from '@/preferences.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index 5e1dedd709..bb2b201afb 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -54,9 +54,9 @@ import MkContainer from '@/components/MkContainer.vue';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { store } from '@/store.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { prefer } from '@/preferences.js';
 import { PREF_DEF } from '@/preferences/def.js';
 
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 1e7436bf9c..dfbcf91a3f 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -71,9 +71,9 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
 import { signinRequired } from '@/account.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
 import { notificationTypes } from '@@/js/const.js';
 
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 21133d72e7..f2d998579f 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -116,12 +116,12 @@ import FormInfo from '@/components/MkInfo.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { signout, signinRequired } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import FormSection from '@/components/form/section.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
index c79aec91c0..c4db223050 100644
--- a/packages/frontend/src/pages/settings/plugin.install.vue
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -23,9 +23,9 @@ import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormInfo from '@/components/MkInfo.vue';
 import * as os from '@/os.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { installPlugin } from '@/plugin.js';
 
 const code = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index fe57812a1d..741c776bb8 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -102,10 +102,10 @@ import MkCode from '@/components/MkCode.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import * as os from '@/os.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin } from '@/plugin.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index a1aa0e77ec..7e079940a5 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -374,10 +374,10 @@ import MkLink from '@/components/MkLink.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
 import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index 0149241337..7660a591b4 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -178,13 +178,13 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { signinRequired } from '@/account.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import FormSlot from '@/components/form/slot.vue';
-import { formatDateTimeString } from '@/scripts/format-time-string.js';
+import { formatDateTimeString } from '@/utility/format-time-string.js';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
 import MkDisableSection from '@/components/MkDisableSection.vue';
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index f9ddbbc9ed..5efd986ce7 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -161,13 +161,13 @@ import MkSelect from '@/components/MkSelect.vue';
 import FormSplit from '@/components/form/split.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import FormSlot from '@/components/form/slot.vue';
-import { selectFile } from '@/scripts/select-file.js';
+import { selectFile } from '@/utility/select-file.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { signinRequired } from '@/account.js';
-import { langmap } from '@/scripts/langmap.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { langmap } from '@/utility/langmap.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { store } from '@/store.js';
 import { globalEvents } from '@/events.js';
 import MkInfo from '@/components/MkInfo.vue';
diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue
index 5346a58a79..4b0daff7fa 100644
--- a/packages/frontend/src/pages/settings/roles.vue
+++ b/packages/frontend/src/pages/settings/roles.vue
@@ -28,7 +28,7 @@ import FormSection from '@/components/form/section.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { signinRequired } from '@/account.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue
index f365146e0a..fec760582c 100644
--- a/packages/frontend/src/pages/settings/security.vue
+++ b/packages/frontend/src/pages/settings/security.vue
@@ -56,9 +56,9 @@ import FormSlot from '@/components/form/slot.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const pagination = {
 	endpoint: 'i/signin-history' as const,
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index 56f65e2309..1bac19fe47 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -32,15 +32,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, computed, watch } from 'vue';
-import type { SoundType } from '@/scripts/sound.js';
+import type { SoundType } from '@/utility/sound.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js';
-import { selectFile } from '@/scripts/select-file.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js';
+import { selectFile } from '@/utility/select-file.js';
 
 const props = defineProps<{
 	type: SoundType;
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 808ae06f41..7d800a8a37 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref } from 'vue';
 import XSound from './sounds.sound.vue';
 import type { Ref } from 'vue';
-import type { SoundType, OperationType } from '@/scripts/sound.js';
+import type { SoundType, OperationType } from '@/utility/sound.js';
 import type { SoundStore } from '@/preferences/def.js';
 import { prefer } from '@/preferences.js';
 import MkRange from '@/components/MkRange.vue';
@@ -65,8 +65,8 @@ import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { operationTypes } from '@/scripts/sound.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { operationTypes } from '@/utility/sound.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 import { PREF_DEF } from '@/preferences/def.js';
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index ede395e51e..55d976295a 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -96,7 +96,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
index 068d28bc4e..2737af3aa4 100644
--- a/packages/frontend/src/pages/settings/statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -21,9 +21,9 @@ import { v4 as uuid } from 'uuid';
 import XStatusbar from './statusbar.statusbar.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkButton from '@/components/MkButton.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { prefer } from '@/preferences.js';
 
 const statusbars = prefer.r.statusbars;
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
index b19b12aaab..536e624a2d 100644
--- a/packages/frontend/src/pages/settings/theme.install.vue
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -20,10 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed } from 'vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkButton from '@/components/MkButton.vue';
-import { parseThemeCode, previewTheme, installTheme } from '@/scripts/theme.js';
+import { parseThemeCode, previewTheme, installTheme } from '@/utility/theme.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const installThemeCode = ref<string | null>(null);
 
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
index f63f15fc13..2b952cb4e3 100644
--- a/packages/frontend/src/pages/settings/theme.manage.vue
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -37,13 +37,13 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
-import { getBuiltinThemesRef } from '@/scripts/theme.js';
-import type { Theme } from '@/scripts/theme.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { getBuiltinThemesRef } from '@/utility/theme.js';
+import type { Theme } from '@/utility/theme.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { getThemes, removeTheme } from '@/theme-store.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
index 41de2aa6a6..0254dbda5b 100644
--- a/packages/frontend/src/pages/settings/theme.vue
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -83,17 +83,17 @@ import MkSelect from '@/components/MkSelect.vue';
 import FormSection from '@/components/form/section.vue';
 import FormLink from '@/components/form/link.vue';
 import MkButton from '@/components/MkButton.vue';
-import { getBuiltinThemesRef } from '@/scripts/theme.js';
-import { selectFile } from '@/scripts/select-file.js';
-import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
+import { getBuiltinThemesRef } from '@/utility/theme.js';
+import { selectFile } from '@/utility/select-file.js';
+import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { uniqueBy } from '@/scripts/array.js';
+import { uniqueBy } from '@/utility/array.js';
 import { getThemes } from '@/theme-store.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { reloadAsk } from '@/scripts/reload-ask.js';
+import { reloadAsk } from '@/utility/reload-ask.js';
 import { prefer } from '@/preferences.js';
 
 const installedThemes = ref(getThemes());
diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue
index 22b008fb61..83902fe6ba 100644
--- a/packages/frontend/src/pages/settings/webhook.edit.vue
+++ b/packages/frontend/src/pages/settings/webhook.edit.vue
@@ -76,9 +76,9 @@ import FormSection from '@/components/form/section.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue
index 727c4df2d6..ef5b7393bb 100644
--- a/packages/frontend/src/pages/settings/webhook.new.vue
+++ b/packages/frontend/src/pages/settings/webhook.new.vue
@@ -46,7 +46,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const name = ref('');
 const url = ref('');
diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue
index af8b7ca945..7f84d87973 100644
--- a/packages/frontend/src/pages/settings/webhook.vue
+++ b/packages/frontend/src/pages/settings/webhook.vue
@@ -37,7 +37,7 @@ import { computed } from 'vue';
 import MkPagination from '@/components/MkPagination.vue';
 import FormSection from '@/components/form/section.vue';
 import FormLink from '@/components/form/link.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
 const pagination = {
diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue
index 37f6558d64..fc93f86a5b 100644
--- a/packages/frontend/src/pages/share.vue
+++ b/packages/frontend/src/pages/share.vue
@@ -37,9 +37,9 @@ import * as Misskey from 'misskey-js';
 import MkButton from '@/components/MkButton.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { postMessageToParentWindow } from '@/scripts/post-message.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
+import { postMessageToParentWindow } from '@/utility/post-message.js';
 import { i18n } from '@/i18n.js';
 
 const urlParams = new URLSearchParams(window.location.search);
diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue
index 14fb96d4f1..c3f1b4b18e 100644
--- a/packages/frontend/src/pages/signup-complete.vue
+++ b/packages/frontend/src/pages/signup-complete.vue
@@ -31,7 +31,7 @@ import MkAnimBg from '@/components/MkAnimBg.vue';
 import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 
 const submitting = ref(false);
 
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
index 5fc921c55c..f7e0302ca7 100644
--- a/packages/frontend/src/pages/tag.vue
+++ b/packages/frontend/src/pages/tag.vue
@@ -23,12 +23,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref } from 'vue';
 import MkNotes from '@/components/MkNotes.vue';
 import MkButton from '@/components/MkButton.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
 
 const props = defineProps<{
 	tag: string;
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index c701030f9e..50445d8a34 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -81,19 +81,19 @@ import JSON5 from 'json5';
 import lightTheme from '@@/themes/_light.json5';
 import darkTheme from '@@/themes/_dark.json5';
 import { host } from '@@/js/config.js';
-import type { Theme } from '@/scripts/theme.js';
+import type { Theme } from '@/utility/theme.js';
 import MkButton from '@/components/MkButton.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import { $i } from '@/account.js';
-import { applyTheme } from '@/scripts/theme.js';
+import { applyTheme } from '@/utility/theme.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { addTheme } from '@/theme-store.js';
 import { i18n } from '@/i18n.js';
-import { useLeaveGuard } from '@/scripts/use-leave-guard.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { useLeaveGuard } from '@/utility/use-leave-guard.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { prefer } from '@/preferences.js';
 
 const bgColors = [
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 4c15194672..ab7d22ec59 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -45,14 +45,14 @@ import MkInfo from '@/components/MkInfo.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
-import { deviceKind } from '@/scripts/device-kind.js';
-import { deepMerge } from '@/scripts/merge.js';
+import { deviceKind } from '@/utility/device-kind.js';
+import { deepMerge } from '@/utility/merge.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index 3efeb46c0a..7ab1c91975 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -28,8 +28,8 @@ import { computed, watch, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/router/supplier.js';
 
diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue
index a77493fe47..af543ac723 100644
--- a/packages/frontend/src/pages/user-tag.vue
+++ b/packages/frontend/src/pages/user-tag.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed } from 'vue';
 import MkUserList from '@/components/MkUserList.vue';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 
 const props = defineProps<{
 	tag: string;
diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue
index 403e74904c..b78ac2dc17 100644
--- a/packages/frontend/src/pages/user/achievements.vue
+++ b/packages/frontend/src/pages/user/achievements.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkAchievements from '@/components/MkAchievements.vue';
-import { claimAchievement } from '@/scripts/achievements.js';
+import { claimAchievement } from '@/utility/achievements.js';
 import { $i } from '@/account.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index 64d20f7945..b4c0fe4052 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -19,12 +19,12 @@ import { Chart } from 'chart.js';
 import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { initChart } from '@/scripts/init-chart.js';
-import { chartLegend } from '@/scripts/chart-legend.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { initChart } from '@/utility/init-chart.js';
+import { chartLegend } from '@/utility/chart-legend.js';
 import MkChartLegend from '@/components/MkChartLegend.vue';
 
 initChart();
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index cb171928eb..56c5204817 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -19,12 +19,12 @@ import { Chart } from 'chart.js';
 import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { initChart } from '@/scripts/init-chart.js';
-import { chartLegend } from '@/scripts/chart-legend.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { initChart } from '@/utility/init-chart.js';
+import { chartLegend } from '@/utility/chart-legend.js';
 import MkChartLegend from '@/components/MkChartLegend.vue';
 
 initChart();
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index 2804211f95..1b81fda95f 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -19,12 +19,12 @@ import { Chart } from 'chart.js';
 import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
-import { chartVLine } from '@/scripts/chart-vline.js';
-import { initChart } from '@/scripts/init-chart.js';
-import { chartLegend } from '@/scripts/chart-legend.js';
+import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { chartVLine } from '@/utility/chart-vline.js';
+import { initChart } from '@/utility/init-chart.js';
+import { chartLegend } from '@/utility/chart-legend.js';
 import MkChartLegend from '@/components/MkChartLegend.vue';
 
 initChart();
diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue
index 70883242e5..f970401646 100644
--- a/packages/frontend/src/pages/user/followers.vue
+++ b/packages/frontend/src/pages/user/followers.vue
@@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XFollowList from './follow-list.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue
index 37b25f694f..08a6630873 100644
--- a/packages/frontend/src/pages/user/following.vue
+++ b/packages/frontend/src/pages/user/following.vue
@@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XFollowList from './follow-list.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 932d944dde..6450f1e077 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -171,18 +171,18 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkOmit from '@/components/MkOmit.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkButton from '@/components/MkButton.vue';
-import { getUserMenu } from '@/scripts/get-user-menu.js';
+import { getUserMenu } from '@/utility/get-user-menu.js';
 import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { $i, iAmModerator } from '@/account.js';
 import { dateString } from '@/filters/date.js';
-import { confetti } from '@/scripts/confetti.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
+import { confetti } from '@/utility/confetti.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
 import { useRouter } from '@/router/supplier.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import MkSparkle from '@/components/MkSparkle.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue
index b5e5f29ade..6c3b8408fb 100644
--- a/packages/frontend/src/pages/user/index.files.vue
+++ b/packages/frontend/src/pages/user/index.files.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { i18n } from '@/i18n.js';
 import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index c43f6c76d9..3b27f3a08c 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -35,8 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent, computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { acct as getAcct } from '@/filters/user.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index 34c5c3ce6c..ebbe27e3d4 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -42,9 +42,9 @@ import XTimeline from './welcome.timeline.vue';
 import MarqueeText from '@/components/MkMarquee.vue';
 import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
 import misskeysvg from '/client-assets/misskey.svg';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
-import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { instance as meta } from '@/instance.js';
 
 const instances = ref<Misskey.entities.FederationInstance[]>();
diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue
index 33cc139a45..939ca934e8 100644
--- a/packages/frontend/src/pages/welcome.setup.vue
+++ b/packages/frontend/src/pages/welcome.setup.vue
@@ -44,7 +44,7 @@ import { host, version } from '@@/js/config.js';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import MkAnimBg from '@/components/MkAnimBg.vue';
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 9be3a80a9e..180b2e5f77 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { onUpdated, ref, shallowRef } from 'vue';
 import XNote from '@/pages/welcome.timeline.note.vue';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { getScrollContainer } from '@@/js/scroll.js';
 
 const notes = ref<Misskey.entities.Note[]>([]);
diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue
index 38d257506c..724356737c 100644
--- a/packages/frontend/src/pages/welcome.vue
+++ b/packages/frontend/src/pages/welcome.vue
@@ -16,7 +16,7 @@ import * as Misskey from 'misskey-js';
 import XSetup from './welcome.setup.vue';
 import XEntrance from './welcome.entrance.a.vue';
 import { instanceName } from '@@/js/config.js';
-import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { definePageMetadata } from '@/utility/page-metadata.js';
 import { fetchInstance } from '@/instance.js';
 
 const instance = ref<Misskey.entities.MetaDetailed | null>(null);
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts
index 778d5c1981..f53f19f342 100644
--- a/packages/frontend/src/pizzax.ts
+++ b/packages/frontend/src/pizzax.ts
@@ -9,12 +9,12 @@ import { onUnmounted, ref, watch } from 'vue';
 import { BroadcastChannel } from 'broadcast-channel';
 import type { Ref } from 'vue';
 import { $i } from '@/account.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { get, set } from '@/scripts/idb-proxy.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { get, set } from '@/utility/idb-proxy.js';
 import { store } from '@/store.js';
 import { useStream } from '@/stream.js';
-import { deepClone } from '@/scripts/clone.js';
-import { deepMerge } from '@/scripts/merge.js';
+import { deepClone } from '@/utility/clone.js';
+import { deepMerge } from '@/utility/merge.js';
 
 type StateDef = Record<string, {
 	where: 'account' | 'device' | 'deviceAccount';
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index 609c819053..4d56be75b9 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -7,10 +7,10 @@ import { ref, defineAsyncComponent } from 'vue';
 import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
 import { compareVersions } from 'compare-versions';
 import { v4 as uuid } from 'uuid';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors, store } from '@/store.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index 19284de6a1..d17cff0f5d 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -5,10 +5,10 @@
 
 import * as Misskey from 'misskey-js';
 import { hemisphere } from '@@/js/intl-const.js';
-import type { Theme } from '@/scripts/theme.js';
-import type { SoundType } from '@/scripts/sound.js';
+import type { Theme } from '@/utility/theme.js';
+import type { SoundType } from '@/utility/sound.js';
 import type { Plugin } from '@/plugin.js';
-import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
+import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
 
 /** サウンド設定 */
 export type SoundStore = {
diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts
index 516fa2b557..1d03639d37 100644
--- a/packages/frontend/src/preferences/profile.ts
+++ b/packages/frontend/src/preferences/profile.ts
@@ -11,7 +11,7 @@ import { PREF_DEF } from './def.js';
 import { Store } from './store.js';
 import type { MenuItem } from '@/types/menu.js';
 import { $i } from '@/account.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 
 // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index e9dd088462..2d1921f8a9 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -6,15 +6,15 @@
 import { ref, watch } from 'vue';
 import type { PreferencesProfile } from './profile.js';
 import type { MenuItem } from '@/types/menu.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer, profileManager } from '@/preferences.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { $i } from '@/account.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { unisonReload } from '@/utility/unison-reload.js';
 
 function canAutoBackup() {
 	return profileManager.profile.name != null && profileManager.profile.name.trim() !== '';
diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts
deleted file mode 100644
index f5d0ab559f..0000000000
--- a/packages/frontend/src/scripts/achievements.ts
+++ /dev/null
@@ -1,513 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { $i } from '@/account.js';
-
-export const ACHIEVEMENT_TYPES = [
-	'notes1',
-	'notes10',
-	'notes100',
-	'notes500',
-	'notes1000',
-	'notes5000',
-	'notes10000',
-	'notes20000',
-	'notes30000',
-	'notes40000',
-	'notes50000',
-	'notes60000',
-	'notes70000',
-	'notes80000',
-	'notes90000',
-	'notes100000',
-	'login3',
-	'login7',
-	'login15',
-	'login30',
-	'login60',
-	'login100',
-	'login200',
-	'login300',
-	'login400',
-	'login500',
-	'login600',
-	'login700',
-	'login800',
-	'login900',
-	'login1000',
-	'passedSinceAccountCreated1',
-	'passedSinceAccountCreated2',
-	'passedSinceAccountCreated3',
-	'loggedInOnBirthday',
-	'loggedInOnNewYearsDay',
-	'noteClipped1',
-	'noteFavorited1',
-	'myNoteFavorited1',
-	'profileFilled',
-	'markedAsCat',
-	'following1',
-	'following10',
-	'following50',
-	'following100',
-	'following300',
-	'followers1',
-	'followers10',
-	'followers50',
-	'followers100',
-	'followers300',
-	'followers500',
-	'followers1000',
-	'collectAchievements30',
-	'viewAchievements3min',
-	'iLoveMisskey',
-	'foundTreasure',
-	'client30min',
-	'client60min',
-	'noteDeletedWithin1min',
-	'postedAtLateNight',
-	'postedAt0min0sec',
-	'selfQuote',
-	'htl20npm',
-	'viewInstanceChart',
-	'outputHelloWorldOnScratchpad',
-	'open3windows',
-	'driveFolderCircularReference',
-	'reactWithoutRead',
-	'clickedClickHere',
-	'justPlainLucky',
-	'setNameToSyuilo',
-	'cookieClicked',
-	'brainDiver',
-	'smashTestNotificationButton',
-	'tutorialCompleted',
-	'bubbleGameExplodingHead',
-	'bubbleGameDoubleExplodingHead',
-] as const;
-
-export const ACHIEVEMENT_BADGES = {
-	'notes1': {
-		img: '/fluent-emoji/1f4dd.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'notes10': {
-		img: '/fluent-emoji/1f4d1.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'notes100': {
-		img: '/fluent-emoji/1f4d2.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'notes500': {
-		img: '/fluent-emoji/1f4da.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'notes1000': {
-		img: '/fluent-emoji/1f5c3.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'notes5000': {
-		img: '/fluent-emoji/1f304.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'notes10000': {
-		img: '/fluent-emoji/1f3d9.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'silver',
-	},
-	'notes20000': {
-		img: '/fluent-emoji/1f307.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'silver',
-	},
-	'notes30000': {
-		img: '/fluent-emoji/1f306.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'silver',
-	},
-	'notes40000': {
-		img: '/fluent-emoji/1f303.png',
-		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
-		frame: 'silver',
-	},
-	'notes50000': {
-		img: '/fluent-emoji/1fa90.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'gold',
-	},
-	'notes60000': {
-		img: '/fluent-emoji/2604.png',
-		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
-		frame: 'gold',
-	},
-	'notes70000': {
-		img: '/fluent-emoji/1f30c.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'gold',
-	},
-	'notes80000': {
-		img: '/fluent-emoji/1f30c.png',
-		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
-		frame: 'gold',
-	},
-	'notes90000': {
-		img: '/fluent-emoji/1f30c.png',
-		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
-		frame: 'gold',
-	},
-	'notes100000': {
-		img: '/fluent-emoji/267e.png',
-		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
-		frame: 'platinum',
-	},
-	'login3': {
-		img: '/fluent-emoji/1f331.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'login7': {
-		img: '/fluent-emoji/1f331.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'login15': {
-		img: '/fluent-emoji/1f331.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'bronze',
-	},
-	'login30': {
-		img: '/fluent-emoji/1fab4.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'login60': {
-		img: '/fluent-emoji/1fab4.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'login100': {
-		img: '/fluent-emoji/1fab4.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'silver',
-	},
-	'login200': {
-		img: '/fluent-emoji/1f333.png',
-		bg: null,
-		frame: 'silver',
-	},
-	'login300': {
-		img: '/fluent-emoji/1f333.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'silver',
-	},
-	'login400': {
-		img: '/fluent-emoji/1f333.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'silver',
-	},
-	'login500': {
-		img: '/fluent-emoji/1f304.png',
-		bg: null,
-		frame: 'silver',
-	},
-	'login600': {
-		img: '/fluent-emoji/1f304.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'gold',
-	},
-	'login700': {
-		img: '/fluent-emoji/1f304.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'gold',
-	},
-	'login800': {
-		img: '/fluent-emoji/1f307.png',
-		bg: null,
-		frame: 'gold',
-	},
-	'login900': {
-		img: '/fluent-emoji/1f307.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'gold',
-	},
-	'login1000': {
-		img: '/fluent-emoji/1f307.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'platinum',
-	},
-	'noteClipped1': {
-		img: '/fluent-emoji/1f587.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'noteFavorited1': {
-		img: '/fluent-emoji/1f31f.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'myNoteFavorited1': {
-		img: '/fluent-emoji/1f320.png',
-		bg: null,
-		frame: 'silver',
-	},
-	'profileFilled': {
-		img: '/fluent-emoji/1f44c.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'bronze',
-	},
-	'markedAsCat': {
-		img: '/fluent-emoji/1f408.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'bronze',
-	},
-	'following1': {
-		img: '/fluent-emoji/2618.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'following10': {
-		img: '/fluent-emoji/1f6b8.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'following50': {
-		img: '/fluent-emoji/1f91d.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'following100': {
-		img: '/fluent-emoji/1f4af.png',
-		bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))',
-		frame: 'silver',
-	},
-	'following300': {
-		img: '/fluent-emoji/1f970.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'silver',
-	},
-	'followers1': {
-		img: '/fluent-emoji/2618.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'followers10': {
-		img: '/fluent-emoji/1f44b.png',
-		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
-		frame: 'bronze',
-	},
-	'followers50': {
-		img: '/fluent-emoji/1f411.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'bronze',
-	},
-	'followers100': {
-		img: '/fluent-emoji/1f60e.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'silver',
-	},
-	'followers300': {
-		img: '/fluent-emoji/1f3c6.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'silver',
-	},
-	'followers500': {
-		img: '/fluent-emoji/1f4e1.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'gold',
-	},
-	'followers1000': {
-		img: '/fluent-emoji/1f451.png',
-		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
-		frame: 'platinum',
-	},
-	'collectAchievements30': {
-		img: '/fluent-emoji/1f3c5.png',
-		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
-		frame: 'silver',
-	},
-	'viewAchievements3min': {
-		img: '/fluent-emoji/1f3c5.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'bronze',
-	},
-	'iLoveMisskey': {
-		img: '/fluent-emoji/2764.png',
-		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
-		frame: 'silver',
-	},
-	'foundTreasure': {
-		img: '/fluent-emoji/1f3c6.png',
-		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
-		frame: 'gold',
-	},
-	'client30min': {
-		img: '/fluent-emoji/1f552.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'bronze',
-	},
-	'client60min': {
-		img: '/fluent-emoji/1f552.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'silver',
-	},
-	'noteDeletedWithin1min': {
-		img: '/fluent-emoji/1f5d1.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'bronze',
-	},
-	'postedAtLateNight': {
-		img: '/fluent-emoji/1f319.png',
-		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
-		frame: 'bronze',
-	},
-	'postedAt0min0sec': {
-		img: '/fluent-emoji/1f55b.png',
-		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
-		frame: 'bronze',
-	},
-	'selfQuote': {
-		img: '/fluent-emoji/1f4dd.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'htl20npm': {
-		img: '/fluent-emoji/1f30a.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'bronze',
-	},
-	'viewInstanceChart': {
-		img: '/fluent-emoji/1f4ca.png',
-		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
-		frame: 'bronze',
-	},
-	'outputHelloWorldOnScratchpad': {
-		img: '/fluent-emoji/1f530.png',
-		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
-		frame: 'bronze',
-	},
-	'open3windows': {
-		img: '/fluent-emoji/1f5a5.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'bronze',
-	},
-	'driveFolderCircularReference': {
-		img: '/fluent-emoji/1f4c2.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'bronze',
-	},
-	'reactWithoutRead': {
-		img: '/fluent-emoji/2753.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'bronze',
-	},
-	'clickedClickHere': {
-		img: '/fluent-emoji/2757.png',
-		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
-		frame: 'bronze',
-	},
-	'justPlainLucky': {
-		img: '/fluent-emoji/1f340.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'silver',
-	},
-	'setNameToSyuilo': {
-		img: '/fluent-emoji/1f36e.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'bronze',
-	},
-	'passedSinceAccountCreated1': {
-		img: '/fluent-emoji/0031-20e3.png',
-		bg: null,
-		frame: 'bronze',
-	},
-	'passedSinceAccountCreated2': {
-		img: '/fluent-emoji/0032-20e3.png',
-		bg: null,
-		frame: 'silver',
-	},
-	'passedSinceAccountCreated3': {
-		img: '/fluent-emoji/0033-20e3.png',
-		bg: null,
-		frame: 'gold',
-	},
-	'loggedInOnBirthday': {
-		img: '/fluent-emoji/1f382.png',
-		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
-		frame: 'silver',
-	},
-	'loggedInOnNewYearsDay': {
-		img: '/fluent-emoji/1f38d.png',
-		bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))',
-		frame: 'silver',
-	},
-	'cookieClicked': {
-		img: '/fluent-emoji/1f36a.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'bronze',
-	},
-	'brainDiver': {
-		img: '/fluent-emoji/1f9e0.png',
-		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
-		frame: 'bronze',
-	},
-	'smashTestNotificationButton': {
-		img: '/fluent-emoji/1f514.png',
-		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
-		frame: 'bronze',
-	},
-	'tutorialCompleted': {
-		img: '/fluent-emoji/1f393.png',
-		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
-		frame: 'bronze',
-	},
-	'bubbleGameExplodingHead': {
-		img: '/fluent-emoji/1f92f.png',
-		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
-		frame: 'bronze',
-	},
-	'bubbleGameDoubleExplodingHead': {
-		img: '/fluent-emoji/1f92f.png',
-		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
-		frame: 'silver',
-	},
-/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
-} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
-	img: string;
-	bg: string | null;
-	frame: 'bronze' | 'silver' | 'gold' | 'platinum';
-}>;
- */
-} as const;
-
-export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
-
-const claimingQueue = new Set<string>();
-
-export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
-	if ($i == null) return;
-	if ($i.movedTo) return;
-	if (claimedAchievements.includes(type)) return;
-	claimingQueue.add(type);
-	claimedAchievements.push(type);
-	await new Promise(resolve => setTimeout(resolve, (claimingQueue.size - 1) * 500));
-	window.setTimeout(() => {
-		claimingQueue.delete(type);
-	}, 500);
-	misskeyApi('i/claim-achievement', { name: type });
-}
-
-if (_DEV_) {
-	(window as any).unlockAllAchievements = () => {
-		for (const t of ACHIEVEMENT_TYPES) {
-			claimAchievement(t);
-		}
-	};
-}
diff --git a/packages/frontend/src/scripts/admin-lookup.ts b/packages/frontend/src/scripts/admin-lookup.ts
deleted file mode 100644
index 1b57b853c9..0000000000
--- a/packages/frontend/src/scripts/admin-lookup.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { i18n } from '@/i18n.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-
-export async function lookupUser() {
-	const { canceled, result } = await os.inputText({
-		title: i18n.ts.usernameOrUserId,
-	});
-	if (canceled) return;
-
-	const show = (user) => {
-		os.pageWindow(`/admin/user/${user.id}`);
-	};
-
-	const usernamePromise = misskeyApi('users/show', Misskey.acct.parse(result));
-	const idPromise = misskeyApi('users/show', { userId: result });
-	let _notFound = false;
-	const notFound = () => {
-		if (_notFound) {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.noSuchUser,
-			});
-		} else {
-			_notFound = true;
-		}
-	};
-	usernamePromise.then(show).catch(err => {
-		if (err.code === 'NO_SUCH_USER') {
-			notFound();
-		}
-	});
-	idPromise.then(show).catch(err => {
-		notFound();
-	});
-}
-
-export async function lookupUserByEmail() {
-	const { canceled, result } = await os.inputText({
-		title: i18n.ts.emailAddress,
-		type: 'email',
-	});
-	if (canceled) return;
-
-	try {
-		const user = await os.apiWithDialog('admin/accounts/find-by-email', { email: result });
-
-		os.pageWindow(`/admin/user/${user.id}`);
-	} catch (err) {
-		if (err.code === 'USER_NOT_FOUND') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.noSuchUser,
-			});
-		} else {
-			throw err;
-		}
-	}
-}
-
-export async function lookupFile() {
-	const { canceled, result: q } = await os.inputText({
-		title: i18n.ts.fileIdOrUrl,
-		minLength: 1,
-	});
-	if (canceled) return;
-
-	const show = (file) => {
-		os.pageWindow(`/admin/file/${file.id}`);
-	};
-
-	misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
-		show(file);
-	}).catch(err => {
-		if (err.code === 'NO_SUCH_FILE') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts.notFound,
-			});
-		}
-	});
-}
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
deleted file mode 100644
index 2c0c8c816e..0000000000
--- a/packages/frontend/src/scripts/aiscript/api.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { errors, utils, values } from '@syuilo/aiscript';
-import * as Misskey from 'misskey-js';
-import { url, lang } from '@@/js/config.js';
-import { assertStringAndIsIn } from './common.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { $i } from '@/account.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { customEmojis } from '@/custom-emojis.js';
-
-const DIALOG_TYPES = [
-	'error',
-	'info',
-	'success',
-	'warning',
-	'waiting',
-	'question',
-] as const;
-
-export function aiScriptReadline(q: string): Promise<string> {
-	return new Promise(ok => {
-		os.inputText({
-			title: q,
-		}).then(({ result: a }) => {
-			ok(a ?? '');
-		});
-	});
-}
-
-export function createAiScriptEnv(opts: { storageKey: string, token?: string }) {
-	return {
-		USER_ID: $i ? values.STR($i.id) : values.NULL,
-		USER_NAME: $i?.name ? values.STR($i.name) : values.NULL,
-		USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
-		CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
-		LOCALE: values.STR(lang),
-		SERVER_URL: values.STR(url),
-		'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
-			utils.assertString(title);
-			utils.assertString(text);
-			if (type != null) {
-				assertStringAndIsIn(type, DIALOG_TYPES);
-			}
-			await os.alert({
-				type: type ? type.value : 'info',
-				title: title.value,
-				text: text.value,
-			});
-			return values.NULL;
-		}),
-		'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
-			utils.assertString(title);
-			utils.assertString(text);
-			if (type != null) {
-				assertStringAndIsIn(type, DIALOG_TYPES);
-			}
-			const confirm = await os.confirm({
-				type: type ? type.value : 'question',
-				title: title.value,
-				text: text.value,
-			});
-			return confirm.canceled ? values.FALSE : values.TRUE;
-		}),
-		'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
-			utils.assertString(ep);
-			if (ep.value.includes('://')) {
-				throw new errors.AiScriptRuntimeError('invalid endpoint');
-			}
-			if (token) {
-				utils.assertString(token);
-				// バグがあればundefinedもあり得るため念のため
-				if (typeof token.value !== 'string') throw new Error('invalid token');
-			}
-			const actualToken: string | null = token?.value ?? opts.token ?? null;
-			if (param == null) {
-				throw new errors.AiScriptRuntimeError('expected param');
-			}
-			utils.assertObject(param);
-			return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
-				return utils.jsToVal(res);
-			}, err => {
-				return values.ERROR('request_failed', utils.jsToVal(err));
-			});
-		}),
-		/* セキュリティ上の問題があるため無効化
-		'Mk:apiExternal': values.FN_NATIVE(async ([host, ep, param, token]) => {
-			utils.assertString(host);
-			utils.assertString(ep);
-			if (token) utils.assertString(token);
-			return os.apiExternal(host.value, ep.value, utils.valToJs(param), token?.value).then(res => {
-				return utils.jsToVal(res);
-			}, err => {
-				return values.ERROR('request_failed', utils.jsToVal(err));
-			});
-		}),
-		*/
-		'Mk:save': values.FN_NATIVE(([key, value]) => {
-			utils.assertString(key);
-			utils.expectAny(value);
-			miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value)));
-			return values.NULL;
-		}),
-		'Mk:load': values.FN_NATIVE(([key]) => {
-			utils.assertString(key);
-			return utils.jsToVal(miLocalStorage.getItemAsJson(`aiscript:${opts.storageKey}:${key.value}`) ?? null);
-		}),
-		'Mk:remove': values.FN_NATIVE(([key]) => {
-			utils.assertString(key);
-			miLocalStorage.removeItem(`aiscript:${opts.storageKey}:${key.value}`);
-			return values.NULL;
-		}),
-		'Mk:url': values.FN_NATIVE(() => {
-			return values.STR(window.location.href);
-		}),
-		'Mk:nyaize': values.FN_NATIVE(([text]) => {
-			utils.assertString(text);
-			return values.STR(Misskey.nyaize(text.value));
-		}),
-	};
-}
diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/scripts/aiscript/common.ts
deleted file mode 100644
index ba5dfb8368..0000000000
--- a/packages/frontend/src/scripts/aiscript/common.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { errors, utils } from '@syuilo/aiscript';
-import type { values } from '@syuilo/aiscript';
-
-export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } {
-	utils.assertString(value);
-	const str = value.value;
-	if (!expects.includes(str)) {
-		const expected = expects.map((expect) => `"${expect}"`).join(', ');
-		throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`);
-	}
-}
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
deleted file mode 100644
index 46e193f7c1..0000000000
--- a/packages/frontend/src/scripts/aiscript/ui.ts
+++ /dev/null
@@ -1,655 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { utils, values } from '@syuilo/aiscript';
-import { v4 as uuid } from 'uuid';
-import { ref } from 'vue';
-import type { Ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { assertStringAndIsIn } from './common.js';
-
-const ALIGNS = ['left', 'center', 'right'] as const;
-const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
-const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const;
-
-type Align = (typeof ALIGNS)[number];
-type Font = (typeof FONTS)[number];
-type BorderStyle = (typeof BORDER_STYLES)[number];
-
-export type AsUiComponentBase = {
-	id: string;
-	hidden?: boolean;
-};
-
-export type AsUiRoot = AsUiComponentBase & {
-	type: 'root';
-	children: AsUiComponent['id'][];
-};
-
-export type AsUiContainer = AsUiComponentBase & {
-	type: 'container';
-	children?: AsUiComponent['id'][];
-	align?: Align;
-	bgColor?: string;
-	fgColor?: string;
-	font?: Font;
-	borderWidth?: number;
-	borderColor?: string;
-	borderStyle?: BorderStyle;
-	borderRadius?: number;
-	padding?: number;
-	rounded?: boolean;
-	hidden?: boolean;
-};
-
-export type AsUiText = AsUiComponentBase & {
-	type: 'text';
-	text?: string;
-	size?: number;
-	bold?: boolean;
-	color?: string;
-	font?: Font;
-};
-
-export type AsUiMfm = AsUiComponentBase & {
-	type: 'mfm';
-	text?: string;
-	size?: number;
-	bold?: boolean;
-	color?: string;
-	font?: Font;
-	onClickEv?: (evId: string) => Promise<void>;
-};
-
-export type AsUiButton = AsUiComponentBase & {
-	type: 'button';
-	text?: string;
-	onClick?: () => Promise<void>;
-	primary?: boolean;
-	rounded?: boolean;
-	disabled?: boolean;
-};
-
-export type AsUiButtons = AsUiComponentBase & {
-	type: 'buttons';
-	buttons?: AsUiButton[];
-};
-
-export type AsUiSwitch = AsUiComponentBase & {
-	type: 'switch';
-	onChange?: (v: boolean) => Promise<void>;
-	default?: boolean;
-	label?: string;
-	caption?: string;
-};
-
-export type AsUiTextarea = AsUiComponentBase & {
-	type: 'textarea';
-	onInput?: (v: string) => Promise<void>;
-	default?: string;
-	label?: string;
-	caption?: string;
-};
-
-export type AsUiTextInput = AsUiComponentBase & {
-	type: 'textInput';
-	onInput?: (v: string) => Promise<void>;
-	default?: string;
-	label?: string;
-	caption?: string;
-};
-
-export type AsUiNumberInput = AsUiComponentBase & {
-	type: 'numberInput';
-	onInput?: (v: number) => Promise<void>;
-	default?: number;
-	label?: string;
-	caption?: string;
-};
-
-export type AsUiSelect = AsUiComponentBase & {
-	type: 'select';
-	items?: {
-		text: string;
-		value: string;
-	}[];
-	onChange?: (v: string) => Promise<void>;
-	default?: string;
-	label?: string;
-	caption?: string;
-};
-
-export type AsUiFolder = AsUiComponentBase & {
-	type: 'folder';
-	children?: AsUiComponent['id'][];
-	title?: string;
-	opened?: boolean;
-};
-
-type PostFormPropsForAsUi = {
-	text: string;
-	cw?: string;
-	visibility?: (typeof Misskey.noteVisibilities)[number];
-	localOnly?: boolean;
-};
-
-export type AsUiPostFormButton = AsUiComponentBase & {
-	type: 'postFormButton';
-	text?: string;
-	primary?: boolean;
-	rounded?: boolean;
-	form?: PostFormPropsForAsUi;
-};
-
-export type AsUiPostForm = AsUiComponentBase & {
-	type: 'postForm';
-	form?: PostFormPropsForAsUi;
-};
-
-export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm;
-
-type Options<T extends AsUiComponent> = T extends AsUiButtons
-	? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] }
-	: Omit<T, 'id' | 'type'>;
-
-export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
-	// TODO
-}
-
-function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> {
-	utils.assertObject(def);
-
-	const children = def.value.get('children');
-	utils.assertArray(children);
-
-	return {
-		children: children.value.map(v => {
-			utils.assertObject(v);
-			const id = v.value.get('id');
-			utils.assertString(id);
-			return id.value;
-		}),
-	};
-}
-
-function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> {
-	utils.assertObject(def);
-
-	const children = def.value.get('children');
-	if (children) utils.assertArray(children);
-	const align = def.value.get('align');
-	if (align) assertStringAndIsIn(align, ALIGNS);
-	const bgColor = def.value.get('bgColor');
-	if (bgColor) utils.assertString(bgColor);
-	const fgColor = def.value.get('fgColor');
-	if (fgColor) utils.assertString(fgColor);
-	const font = def.value.get('font');
-	if (font) assertStringAndIsIn(font, FONTS);
-	const borderWidth = def.value.get('borderWidth');
-	if (borderWidth) utils.assertNumber(borderWidth);
-	const borderColor = def.value.get('borderColor');
-	if (borderColor) utils.assertString(borderColor);
-	const borderStyle = def.value.get('borderStyle');
-	if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES);
-	const borderRadius = def.value.get('borderRadius');
-	if (borderRadius) utils.assertNumber(borderRadius);
-	const padding = def.value.get('padding');
-	if (padding) utils.assertNumber(padding);
-	const rounded = def.value.get('rounded');
-	if (rounded) utils.assertBoolean(rounded);
-	const hidden = def.value.get('hidden');
-	if (hidden) utils.assertBoolean(hidden);
-
-	return {
-		children: children ? children.value.map(v => {
-			utils.assertObject(v);
-			const id = v.value.get('id');
-			utils.assertString(id);
-			return id.value;
-		}) : [],
-		align: align?.value,
-		fgColor: fgColor?.value,
-		bgColor: bgColor?.value,
-		font: font?.value,
-		borderWidth: borderWidth?.value,
-		borderColor: borderColor?.value,
-		borderStyle: borderStyle?.value,
-		borderRadius: borderRadius?.value,
-		padding: padding?.value,
-		rounded: rounded?.value,
-		hidden: hidden?.value,
-	};
-}
-
-function getTextOptions(def: values.Value | undefined): Options<AsUiText> {
-	utils.assertObject(def);
-
-	const text = def.value.get('text');
-	if (text) utils.assertString(text);
-	const size = def.value.get('size');
-	if (size) utils.assertNumber(size);
-	const bold = def.value.get('bold');
-	if (bold) utils.assertBoolean(bold);
-	const color = def.value.get('color');
-	if (color) utils.assertString(color);
-	const font = def.value.get('font');
-	if (font) assertStringAndIsIn(font, FONTS);
-
-	return {
-		text: text?.value,
-		size: size?.value,
-		bold: bold?.value,
-		color: color?.value,
-		font: font?.value,
-	};
-}
-
-function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> {
-	utils.assertObject(def);
-
-	const text = def.value.get('text');
-	if (text) utils.assertString(text);
-	const size = def.value.get('size');
-	if (size) utils.assertNumber(size);
-	const bold = def.value.get('bold');
-	if (bold) utils.assertBoolean(bold);
-	const color = def.value.get('color');
-	if (color) utils.assertString(color);
-	const font = def.value.get('font');
-	if (font) assertStringAndIsIn(font, FONTS);
-	const onClickEv = def.value.get('onClickEv');
-	if (onClickEv) utils.assertFunction(onClickEv);
-
-	return {
-		text: text?.value,
-		size: size?.value,
-		bold: bold?.value,
-		color: color?.value,
-		font: font?.value,
-		onClickEv: async (evId: string) => {
-			if (onClickEv) await call(onClickEv, [values.STR(evId)]);
-		},
-	};
-}
-
-function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> {
-	utils.assertObject(def);
-
-	const onInput = def.value.get('onInput');
-	if (onInput) utils.assertFunction(onInput);
-	const defaultValue = def.value.get('default');
-	if (defaultValue) utils.assertString(defaultValue);
-	const label = def.value.get('label');
-	if (label) utils.assertString(label);
-	const caption = def.value.get('caption');
-	if (caption) utils.assertString(caption);
-
-	return {
-		onInput: async (v) => {
-			if (onInput) await call(onInput, [utils.jsToVal(v)]);
-		},
-		default: defaultValue?.value,
-		label: label?.value,
-		caption: caption?.value,
-	};
-}
-
-function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> {
-	utils.assertObject(def);
-
-	const onInput = def.value.get('onInput');
-	if (onInput) utils.assertFunction(onInput);
-	const defaultValue = def.value.get('default');
-	if (defaultValue) utils.assertString(defaultValue);
-	const label = def.value.get('label');
-	if (label) utils.assertString(label);
-	const caption = def.value.get('caption');
-	if (caption) utils.assertString(caption);
-
-	return {
-		onInput: async (v) => {
-			if (onInput) await call(onInput, [utils.jsToVal(v)]);
-		},
-		default: defaultValue?.value,
-		label: label?.value,
-		caption: caption?.value,
-	};
-}
-
-function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> {
-	utils.assertObject(def);
-
-	const onInput = def.value.get('onInput');
-	if (onInput) utils.assertFunction(onInput);
-	const defaultValue = def.value.get('default');
-	if (defaultValue) utils.assertNumber(defaultValue);
-	const label = def.value.get('label');
-	if (label) utils.assertString(label);
-	const caption = def.value.get('caption');
-	if (caption) utils.assertString(caption);
-
-	return {
-		onInput: async (v) => {
-			if (onInput) await call(onInput, [utils.jsToVal(v)]);
-		},
-		default: defaultValue?.value,
-		label: label?.value,
-		caption: caption?.value,
-	};
-}
-
-function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> {
-	utils.assertObject(def);
-
-	const text = def.value.get('text');
-	if (text) utils.assertString(text);
-	const onClick = def.value.get('onClick');
-	if (onClick) utils.assertFunction(onClick);
-	const primary = def.value.get('primary');
-	if (primary) utils.assertBoolean(primary);
-	const rounded = def.value.get('rounded');
-	if (rounded) utils.assertBoolean(rounded);
-	const disabled = def.value.get('disabled');
-	if (disabled) utils.assertBoolean(disabled);
-
-	return {
-		text: text?.value,
-		onClick: async () => {
-			if (onClick) await call(onClick, []);
-		},
-		primary: primary?.value,
-		rounded: rounded?.value,
-		disabled: disabled?.value,
-	};
-}
-
-function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> {
-	utils.assertObject(def);
-
-	const buttons = def.value.get('buttons');
-	if (buttons) utils.assertArray(buttons);
-
-	return {
-		buttons: buttons ? buttons.value.map(button => {
-			utils.assertObject(button);
-			const text = button.value.get('text');
-			utils.assertString(text);
-			const onClick = button.value.get('onClick');
-			utils.assertFunction(onClick);
-			const primary = button.value.get('primary');
-			if (primary) utils.assertBoolean(primary);
-			const rounded = button.value.get('rounded');
-			if (rounded) utils.assertBoolean(rounded);
-			const disabled = button.value.get('disabled');
-			if (disabled) utils.assertBoolean(disabled);
-
-			return {
-				text: text.value,
-				onClick: async () => {
-					await call(onClick, []);
-				},
-				primary: primary?.value,
-				rounded: rounded?.value,
-				disabled: disabled?.value,
-			};
-		}) : [],
-	};
-}
-
-function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> {
-	utils.assertObject(def);
-
-	const onChange = def.value.get('onChange');
-	if (onChange) utils.assertFunction(onChange);
-	const defaultValue = def.value.get('default');
-	if (defaultValue) utils.assertBoolean(defaultValue);
-	const label = def.value.get('label');
-	if (label) utils.assertString(label);
-	const caption = def.value.get('caption');
-	if (caption) utils.assertString(caption);
-
-	return {
-		onChange: async (v) => {
-			if (onChange) await call(onChange, [utils.jsToVal(v)]);
-		},
-		default: defaultValue?.value,
-		label: label?.value,
-		caption: caption?.value,
-	};
-}
-
-function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> {
-	utils.assertObject(def);
-
-	const items = def.value.get('items');
-	if (items) utils.assertArray(items);
-	const onChange = def.value.get('onChange');
-	if (onChange) utils.assertFunction(onChange);
-	const defaultValue = def.value.get('default');
-	if (defaultValue) utils.assertString(defaultValue);
-	const label = def.value.get('label');
-	if (label) utils.assertString(label);
-	const caption = def.value.get('caption');
-	if (caption) utils.assertString(caption);
-
-	return {
-		items: items ? items.value.map(item => {
-			utils.assertObject(item);
-			const text = item.value.get('text');
-			utils.assertString(text);
-			const value = item.value.get('value');
-			if (value) utils.assertString(value);
-			return {
-				text: text.value,
-				value: value ? value.value : text.value,
-			};
-		}) : [],
-		onChange: async (v) => {
-			if (onChange) await call(onChange, [utils.jsToVal(v)]);
-		},
-		default: defaultValue?.value,
-		label: label?.value,
-		caption: caption?.value,
-	};
-}
-
-function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> {
-	utils.assertObject(def);
-
-	const children = def.value.get('children');
-	if (children) utils.assertArray(children);
-	const title = def.value.get('title');
-	if (title) utils.assertString(title);
-	const opened = def.value.get('opened');
-	if (opened) utils.assertBoolean(opened);
-
-	return {
-		children: children ? children.value.map(v => {
-			utils.assertObject(v);
-			const id = v.value.get('id');
-			utils.assertString(id);
-			return id.value;
-		}) : [],
-		title: title?.value ?? '',
-		opened: opened?.value ?? true,
-	};
-}
-
-function getPostFormProps(form: values.VObj): PostFormPropsForAsUi {
-	const text = form.value.get('text');
-	utils.assertString(text);
-	const cw = form.value.get('cw');
-	if (cw) utils.assertString(cw);
-	const visibility = form.value.get('visibility');
-	if (visibility) utils.assertString(visibility);
-	const localOnly = form.value.get('localOnly');
-	if (localOnly) utils.assertBoolean(localOnly);
-
-	return {
-		text: text.value,
-		cw: cw?.value,
-		visibility: (visibility?.value && (Misskey.noteVisibilities as readonly string[]).includes(visibility.value)) ? visibility.value as typeof Misskey.noteVisibilities[number] : undefined,
-		localOnly: localOnly?.value,
-	};
-}
-
-function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> {
-	utils.assertObject(def);
-
-	const text = def.value.get('text');
-	if (text) utils.assertString(text);
-	const primary = def.value.get('primary');
-	if (primary) utils.assertBoolean(primary);
-	const rounded = def.value.get('rounded');
-	if (rounded) utils.assertBoolean(rounded);
-	const form = def.value.get('form');
-	if (form) utils.assertObject(form);
-
-	return {
-		text: text?.value,
-		primary: primary?.value,
-		rounded: rounded?.value,
-		form: form ? getPostFormProps(form) : {
-			text: '',
-		},
-	};
-}
-
-function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> {
-	utils.assertObject(def);
-
-	const form = def.value.get('form');
-	if (form) utils.assertObject(form);
-
-	return {
-		form: form ? getPostFormProps(form) : {
-			text: '',
-		},
-	};
-}
-
-export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
-	type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
-
-	const instances = {};
-
-	function createComponentInstance<T extends AsUiComponent, C>(
-		type: T['type'],
-		def: values.Value | undefined,
-		id: values.Value | undefined,
-		getOptions: OptionsConverter<T, C>,
-		call: C,
-	) {
-		if (id) utils.assertString(id);
-		const _id = id?.value ?? uuid();
-		const component = ref({
-			...getOptions(def, call),
-			type,
-			id: _id,
-		} as T);
-		components.push(component);
-		const instance = values.OBJ(new Map<string, values.Value>([
-			['id', values.STR(_id)],
-			['update', values.FN_NATIVE(([def], opts) => {
-				utils.assertObject(def);
-				const updates = getOptions(def, call);
-				for (const update of def.value.keys()) {
-					if (!Object.hasOwn(updates, update)) continue;
-					component.value[update] = updates[update];
-				}
-			})],
-		]));
-		instances[_id] = instance;
-		return instance;
-	}
-
-	const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {});
-	const rootComponent = components[0] as Ref<AsUiRoot>;
-	done(rootComponent);
-
-	return {
-		'Ui:root': rootInstance,
-
-		'Ui:patch': values.FN_NATIVE(([id, val], opts) => {
-			utils.assertString(id);
-			utils.assertArray(val);
-			// patch(id.value, val.value, opts.call); // TODO
-		}),
-
-		'Ui:get': values.FN_NATIVE(([id], opts) => {
-			utils.assertString(id);
-			const instance = instances[id.value];
-			if (instance) {
-				return instance;
-			} else {
-				return values.NULL;
-			}
-		}),
-
-		// Ui:root.update({ children: [...] }) の糖衣構文
-		'Ui:render': values.FN_NATIVE(([children], opts) => {
-			utils.assertArray(children);
-
-			rootComponent.value.children = children.value.map(v => {
-				utils.assertObject(v);
-				const id = v.value.get('id');
-				utils.assertString(id);
-				return id.value;
-			});
-		}),
-
-		'Ui:C:container': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('container', def, id, getContainerOptions, opts.topCall);
-		}),
-
-		'Ui:C:text': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('text', def, id, getTextOptions, opts.topCall);
-		}),
-
-		'Ui:C:mfm': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('mfm', def, id, getMfmOptions, opts.topCall);
-		}),
-
-		'Ui:C:textarea': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('textarea', def, id, getTextareaOptions, opts.topCall);
-		}),
-
-		'Ui:C:textInput': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('textInput', def, id, getTextInputOptions, opts.topCall);
-		}),
-
-		'Ui:C:numberInput': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.topCall);
-		}),
-
-		'Ui:C:button': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('button', def, id, getButtonOptions, opts.topCall);
-		}),
-
-		'Ui:C:buttons': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('buttons', def, id, getButtonsOptions, opts.topCall);
-		}),
-
-		'Ui:C:switch': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('switch', def, id, getSwitchOptions, opts.topCall);
-		}),
-
-		'Ui:C:select': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('select', def, id, getSelectOptions, opts.topCall);
-		}),
-
-		'Ui:C:folder': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('folder', def, id, getFolderOptions, opts.topCall);
-		}),
-
-		'Ui:C:postFormButton': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.topCall);
-		}),
-
-		'Ui:C:postForm': values.FN_NATIVE(([def, id], opts) => {
-			return createComponentInstance('postForm', def, id, getPostFormOptions, opts.topCall);
-		}),
-	};
-}
diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts
deleted file mode 100644
index f2feb29dfc..0000000000
--- a/packages/frontend/src/scripts/array.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-type EndoRelation<T> = (a: T, b: T) => boolean;
-type Predicate<T> = (x: T) => boolean;
-
-/**
- * Count the number of elements that satisfy the predicate
- */
-
-export function countIf<T>(f: Predicate<T>, xs: T[]): number {
-	return xs.filter(f).length;
-}
-
-/**
- * Count the number of elements that is equal to the element
- */
-export function count<T>(a: T, xs: T[]): number {
-	return countIf(x => x === a, xs);
-}
-
-/**
- * Concatenate an array of arrays
- */
-export function concat<T>(xss: T[][]): T[] {
-	return ([] as T[]).concat(...xss);
-}
-
-/**
- * Intersperse the element between the elements of the array
- * @param sep The element to be interspersed
- */
-export function intersperse<T>(sep: T, xs: T[]): T[] {
-	return concat(xs.map(x => [sep, x])).slice(1);
-}
-
-/**
- * Returns the array of elements that is not equal to the element
- */
-export function erase<T>(a: T, xs: T[]): T[] {
-	return xs.filter(x => x !== a);
-}
-
-/**
- * Finds the array of all elements in the first array not contained in the second array.
- * The order of result values are determined by the first array.
- */
-export function difference<T>(xs: T[], ys: T[]): T[] {
-	return xs.filter(x => !ys.includes(x));
-}
-
-/**
- * Remove all but the first element from every group of equivalent elements
- */
-export function unique<T>(xs: T[]): T[] {
-	return [...new Set(xs)];
-}
-
-export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
-	const map = new Map<TKey, TValue>();
-
-	for (const value of values) {
-		const key = keySelector(value);
-		if (!map.has(key)) map.set(key, value);
-	}
-
-	return [...map.values()];
-}
-
-export function sum(xs: number[]): number {
-	return xs.reduce((a, b) => a + b, 0);
-}
-
-export function maximum(xs: number[]): number {
-	return Math.max(...xs);
-}
-
-/**
- * Compare two arrays by lexicographical order
- */
-export function lessThan(xs: number[], ys: number[]): boolean {
-	for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
-		if (xs[i] < ys[i]) return true;
-		if (xs[i] > ys[i]) return false;
-	}
-	return xs.length < ys.length;
-}
-
-/**
- * Returns the longest prefix of elements that satisfy the predicate
- */
-export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
-	const ys: T[] = [];
-	for (const x of xs) {
-		if (f(x)) {
-			ys.push(x);
-		} else {
-			break;
-		}
-	}
-	return ys;
-}
-
-export function cumulativeSum(xs: number[]): number[] {
-	const ys = Array.from(xs); // deep copy
-	for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
-	return ys;
-}
-
-export function toArray<T>(x: T | T[] | undefined): T[] {
-	return Array.isArray(x) ? x : x != null ? [x] : [];
-}
-
-export function toSingle<T>(x: T | T[] | undefined): T | undefined {
-	return Array.isArray(x) ? x[0] : x;
-}
diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts
deleted file mode 100644
index 9a603b848c..0000000000
--- a/packages/frontend/src/scripts/autocomplete.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { nextTick, ref, defineAsyncComponent } from 'vue';
-import getCaretCoordinates from 'textarea-caret';
-import { toASCII } from 'punycode.js';
-import type { Ref } from 'vue';
-import { popup } from '@/os.js';
-
-export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
-
-export class Autocomplete {
-	private suggestion: {
-		x: Ref<number>;
-		y: Ref<number>;
-		q: Ref<any>;
-		close: () => void;
-	} | null;
-	private textarea: HTMLInputElement | HTMLTextAreaElement;
-	private currentType: string;
-	private textRef: Ref<string | number | null>;
-	private opening: boolean;
-	private onlyType: SuggestionType[];
-
-	private get text(): string {
-		// Use raw .value to get the latest value
-		// (Because v-model does not update while composition)
-		return this.textarea.value;
-	}
-
-	private set text(text: string) {
-		// Use ref value to notify other watchers
-		// (Because .value setter never fires input/change events)
-		this.textRef.value = text;
-	}
-
-	/**
-	 * 対象のテキストエリアを与えてインスタンスを初期化します。
-	 */
-	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string | number | null>, onlyType?: SuggestionType[]) {
-		//#region BIND
-		this.onInput = this.onInput.bind(this);
-		this.complete = this.complete.bind(this);
-		this.close = this.close.bind(this);
-		//#endregion
-
-		this.suggestion = null;
-		this.textarea = textarea;
-		this.textRef = textRef;
-		this.opening = false;
-		this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam'];
-
-		this.attach();
-	}
-
-	/**
-	 * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
-	 */
-	public attach() {
-		this.textarea.addEventListener('input', this.onInput);
-	}
-
-	/**
-	 * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
-	 */
-	public detach() {
-		this.textarea.removeEventListener('input', this.onInput);
-		this.close();
-	}
-
-	/**
-	 * テキスト入力時
-	 */
-	private onInput() {
-		const caretPos = this.textarea.selectionStart;
-		const text = this.text.substring(0, caretPos).split('\n').pop()!;
-
-		const mentionIndex = text.lastIndexOf('@');
-		const hashtagIndex = text.lastIndexOf('#');
-		const emojiIndex = text.lastIndexOf(':');
-		const mfmTagIndex = text.lastIndexOf('$');
-		const mfmParamIndex = text.lastIndexOf('.');
-
-		const max = Math.max(
-			mentionIndex,
-			hashtagIndex,
-			emojiIndex,
-			mfmTagIndex);
-
-		if (max === -1) {
-			this.close();
-			return;
-		}
-
-		const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
-
-		const isMention = mentionIndex !== -1;
-		const isHashtag = hashtagIndex !== -1;
-		const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
-		const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
-		const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
-
-		let opened = false;
-
-		if (isMention && this.onlyType.includes('user')) {
-			// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
-			// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
-			// ホスト名を含むリモートのユーザ名を全て拾えるようにする
-			const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
-			const username = mentionIndexAlt === -1
-				? text.substring(mentionIndex + 1)
-				: text.substring(mentionIndexAlt + 1);
-			if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
-				this.open('user', username);
-				opened = true;
-			} else if (username === '') {
-				this.open('user', null);
-				opened = true;
-			}
-		}
-
-		if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
-			const hashtag = text.substring(hashtagIndex + 1);
-			if (!hashtag.includes(' ')) {
-				this.open('hashtag', hashtag);
-				opened = true;
-			}
-		}
-
-		if (isEmoji && !opened && this.onlyType.includes('emoji')) {
-			const emoji = text.substring(emojiIndex + 1);
-			if (!emoji.includes(' ')) {
-				this.open('emoji', emoji);
-				opened = true;
-			}
-		}
-
-		if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
-			const mfmTag = text.substring(mfmTagIndex + 1);
-			if (!mfmTag.includes(' ')) {
-				this.open('mfmTag', mfmTag.replace('[', ''));
-				opened = true;
-			}
-		}
-
-		if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) {
-			const mfmParam = text.substring(mfmParamIndex + 1);
-			if (!mfmParam.includes(' ')) {
-				this.open('mfmParam', {
-					tag: text.substring(mfmTagIndex + 2, mfmParamIndex),
-					params: mfmParam.split(','),
-				});
-				opened = true;
-			}
-		}
-
-		if (!opened) {
-			this.close();
-		}
-	}
-
-	/**
-	 * サジェストを提示します。
-	 */
-	private async open(type: string, q: any) {
-		if (type !== this.currentType) {
-			this.close();
-		}
-		if (this.opening) return;
-		this.opening = true;
-		this.currentType = type;
-
-		//#region サジェストを表示すべき位置を計算
-		const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
-
-		const rect = this.textarea.getBoundingClientRect();
-
-		const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
-		const y = rect.top + caretPosition.top - this.textarea.scrollTop;
-		//#endregion
-
-		if (this.suggestion) {
-			this.suggestion.x.value = x;
-			this.suggestion.y.value = y;
-			this.suggestion.q.value = q;
-
-			this.opening = false;
-		} else {
-			const _x = ref(x);
-			const _y = ref(y);
-			const _q = ref(q);
-
-			const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
-				textarea: this.textarea,
-				close: this.close,
-				type: type,
-				q: _q,
-				x: _x,
-				y: _y,
-			}, {
-				done: (res) => {
-					this.complete(res);
-				},
-			});
-
-			this.suggestion = {
-				q: _q,
-				x: _x,
-				y: _y,
-				close: () => dispose(),
-			};
-
-			this.opening = false;
-		}
-	}
-
-	/**
-	 * サジェストを閉じます。
-	 */
-	private close() {
-		if (this.suggestion == null) return;
-
-		this.suggestion.close();
-		this.suggestion = null;
-
-		this.textarea.focus();
-	}
-
-	/**
-	 * オートコンプリートする
-	 */
-	private complete({ type, value }) {
-		this.close();
-
-		const caret = this.textarea.selectionStart;
-
-		if (type === 'user') {
-			const source = this.text;
-
-			const before = source.substring(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
-			const after = source.substring(caret);
-
-			const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
-
-			// 挿入
-			this.text = `${trimmedBefore}@${acct} ${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (acct.length + 2);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'hashtag') {
-			const source = this.text;
-
-			const before = source.substring(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
-			const after = source.substring(caret);
-
-			// 挿入
-			this.text = `${trimmedBefore}#${value} ${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (value.length + 2);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'emoji') {
-			const source = this.text;
-
-			const before = source.substring(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
-			const after = source.substring(caret);
-
-			// 挿入
-			this.text = trimmedBefore + value + after;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + value.length;
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'mfmTag') {
-			const source = this.text;
-
-			const before = source.substring(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
-			const after = source.substring(caret);
-
-			// 挿入
-			this.text = `${trimmedBefore}$[${value} ]${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (value.length + 3);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		} else if (type === 'mfmParam') {
-			const source = this.text;
-
-			const before = source.substring(0, caret);
-			const trimmedBefore = before.substring(0, before.lastIndexOf('.'));
-			const after = source.substring(caret);
-
-			// 挿入
-			this.text = `${trimmedBefore}.${value}${after}`;
-
-			// キャレットを戻す
-			nextTick(() => {
-				this.textarea.focus();
-				const pos = trimmedBefore.length + (value.length + 1);
-				this.textarea.setSelectionRange(pos, pos);
-			});
-		}
-	}
-}
diff --git a/packages/frontend/src/scripts/autogen/settings-search-index.ts b/packages/frontend/src/scripts/autogen/settings-search-index.ts
deleted file mode 100644
index 8e2a896b17..0000000000
--- a/packages/frontend/src/scripts/autogen/settings-search-index.ts
+++ /dev/null
@@ -1,822 +0,0 @@
-
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// This file was automatically generated by create-search-index.
-// Do not edit this file.
-
-import { i18n } from '@/i18n.js';
-
-export type SearchIndexItem = {
-	id: string;
-	path?: string;
-	label: string;
-	keywords: string[];
-	icon?: string;
-	children?: SearchIndexItem[];
-};
-
-export const searchIndexes: SearchIndexItem[] = [
-	{
-		id: 'flXd1LC7r',
-		children: [
-			{
-				id: 'hB11H5oul',
-				label: i18n.ts.syncDeviceDarkMode,
-				keywords: ['sync', 'device', 'dark', 'light', 'mode'],
-			},
-			{
-				id: 'fDbLtIKeo',
-				label: i18n.ts.themeForLightMode,
-				keywords: ['light', 'theme'],
-			},
-			{
-				id: 'eLOwK5Ia2',
-				label: i18n.ts.themeForDarkMode,
-				keywords: ['dark', 'theme'],
-			},
-			{
-				id: 'ujvMfyzUr',
-				label: i18n.ts.setWallpaper,
-				keywords: ['wallpaper'],
-			},
-		],
-		label: i18n.ts.theme,
-		keywords: ['theme'],
-		path: '/settings/theme',
-		icon: 'ti ti-palette',
-	},
-	{
-		id: '6fFIRXUww',
-		children: [
-			{
-				id: 'nO7NnzqiC',
-				label: i18n.ts.notUseSound,
-				keywords: ['mute'],
-			},
-			{
-				id: 'oALW4ja7U',
-				label: i18n.ts.useSoundOnlyWhenActive,
-				keywords: ['active', 'mute'],
-			},
-			{
-				id: 'BbJK2SKT2',
-				label: i18n.ts.masterVolume,
-				keywords: ['volume', 'master'],
-			},
-		],
-		label: i18n.ts.sounds,
-		keywords: ['sounds'],
-		path: '/settings/sounds',
-		icon: 'ti ti-music',
-	},
-	{
-		id: '5BjnxMfYV',
-		children: [
-			{
-				id: '3UqdSCaFw',
-				children: [
-					{
-						id: '75QPEg57v',
-						label: i18n.ts.changePassword,
-						keywords: [],
-					},
-				],
-				label: i18n.ts.password,
-				keywords: ['password'],
-			},
-			{
-				id: '2fa',
-				children: [
-					{
-						id: 'qCXM0HtJ7',
-						label: i18n.ts.totp,
-						keywords: ['totp', 'app', i18n.ts.totpDescription],
-					},
-					{
-						id: '3g1RePuD9',
-						label: i18n.ts.securityKeyAndPasskey,
-						keywords: ['security', 'key', 'passkey'],
-					},
-					{
-						id: 'pFRud5u8k',
-						label: i18n.ts.passwordLessLogin,
-						keywords: ['password', 'less', 'key', 'passkey', 'login', 'signin', i18n.ts.passwordLessLoginDescription],
-					},
-				],
-				label: i18n.ts['2fa'],
-				keywords: ['2fa'],
-			},
-		],
-		label: i18n.ts.security,
-		keywords: ['security'],
-		path: '/settings/security',
-		icon: 'ti ti-lock',
-	},
-	{
-		id: 'w4L6myH61',
-		children: [
-			{
-				id: 'ru8DrOn3J',
-				label: i18n.ts._profile.changeBanner,
-				keywords: ['banner', 'change'],
-			},
-			{
-				id: 'CCnD8Apnu',
-				label: i18n.ts._profile.changeAvatar,
-				keywords: ['avatar', 'icon', 'change'],
-			},
-			{
-				id: 'yFEVCJxFX',
-				label: i18n.ts._profile.name,
-				keywords: ['name'],
-			},
-			{
-				id: '2O1S5reaB',
-				label: i18n.ts._profile.description,
-				keywords: ['description', 'bio'],
-			},
-			{
-				id: 'pWi4OLS8g',
-				label: i18n.ts.location,
-				keywords: ['location', 'locale'],
-			},
-			{
-				id: 'oLO5X6Wtw',
-				label: i18n.ts.birthday,
-				keywords: ['birthday', 'birthdate', 'age'],
-			},
-			{
-				id: 'm2trKwPgq',
-				label: i18n.ts.language,
-				keywords: ['language', 'locale'],
-			},
-			{
-				id: 'kfDZxCDp9',
-				label: i18n.ts._profile.metadataEdit,
-				keywords: ['metadata'],
-			},
-			{
-				id: 'uPt3MFymp',
-				label: i18n.ts._profile.followedMessage,
-				keywords: ['follow', 'message', i18n.ts._profile.followedMessageDescription],
-			},
-			{
-				id: 'wuGg0tBjw',
-				label: i18n.ts.reactionAcceptance,
-				keywords: ['reaction'],
-			},
-			{
-				id: 'EezPpmMnf',
-				children: [
-					{
-						id: 'f2cRLh8ad',
-						label: i18n.ts.flagAsCat,
-						keywords: ['cat'],
-					},
-					{
-						id: 'eVoViiF3h',
-						label: i18n.ts.flagAsBot,
-						keywords: ['bot'],
-					},
-				],
-				label: i18n.ts.advancedSettings,
-				keywords: [],
-			},
-		],
-		label: i18n.ts.profile,
-		keywords: ['profile'],
-		path: '/settings/profile',
-		icon: 'ti ti-user',
-	},
-	{
-		id: '2rp9ka5Ht',
-		children: [
-			{
-				id: 'qBUSKPxLW',
-				label: i18n.ts.makeFollowManuallyApprove,
-				keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo],
-			},
-			{
-				id: '3LZBlZCej',
-				label: i18n.ts.autoAcceptFollowed,
-				keywords: ['follow', 'auto', 'accept'],
-			},
-			{
-				id: '9gOp28wKG',
-				label: i18n.ts.makeReactionsPublic,
-				keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription],
-			},
-			{
-				id: 'CjAkqMhct',
-				label: i18n.ts.followingVisibility,
-				keywords: ['following', 'visibility'],
-			},
-			{
-				id: '4nEwI6LYt',
-				label: i18n.ts.followersVisibility,
-				keywords: ['follower', 'visibility'],
-			},
-			{
-				id: 'naMp37wTL',
-				label: i18n.ts.hideOnlineStatus,
-				keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription],
-			},
-			{
-				id: 'p0dCVR0UP',
-				label: i18n.ts.noCrawle,
-				keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription],
-			},
-			{
-				id: 'aceURmNPq',
-				label: i18n.ts.preventAiLearning,
-				keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription],
-			},
-			{
-				id: 'ahABA0j7u',
-				label: i18n.ts.makeExplorable,
-				keywords: ['explore', i18n.ts.makeExplorableDescription],
-			},
-			{
-				id: 'cyeDbLN8N',
-				children: [
-					{
-						id: 'xEYlOghao',
-						label: i18n.ts._accountSettings.requireSigninToViewContents,
-						keywords: ['login', 'signin'],
-					},
-					{
-						id: 'sMmYFCS60',
-						label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
-						keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
-					},
-					{
-						id: '2prkeWRSd',
-						label: i18n.ts._accountSettings.makeNotesHiddenBefore,
-						keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
-					},
-				],
-				label: i18n.ts.lockdown,
-				keywords: ['lockdown'],
-			},
-		],
-		label: i18n.ts.privacy,
-		keywords: ['privacy'],
-		path: '/settings/privacy',
-		icon: 'ti ti-lock-open',
-	},
-	{
-		id: '3yCAv0IsZ',
-		children: [
-			{
-				id: 'x1GWSQnPw',
-				label: i18n.ts.uiLanguage,
-				keywords: ['language'],
-			},
-			{
-				id: 'EOSa4rtt3',
-				label: i18n.ts.overridedDeviceKind,
-				keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'],
-			},
-			{
-				id: 'm9LhX8BG8',
-				label: i18n.ts.showFixedPostForm,
-				keywords: ['post', 'form', 'timeline'],
-			},
-			{
-				id: 'snyCQ5oKE',
-				label: i18n.ts.showFixedPostFormInChannel,
-				keywords: ['post', 'form', 'timeline', 'channel'],
-			},
-			{
-				id: '8j36S4Ev6',
-				label: i18n.ts.pinnedList,
-				keywords: ['pinned', 'list'],
-			},
-			{
-				id: 'CWpyT9vLK',
-				label: i18n.ts.enableQuickAddMfmFunction,
-				keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'],
-			},
-			{
-				id: '1yhown1Xc',
-				label: i18n.ts.rememberNoteVisibility,
-				keywords: ['remember', 'keep', 'note', 'visibility'],
-			},
-			{
-				id: 'wUeAI5QBV',
-				label: i18n.ts.defaultNoteVisibility,
-				keywords: ['default', 'note', 'visibility'],
-			},
-			{
-				id: '6kMj4HVOg',
-				children: [
-					{
-						id: 'DQIcvf64G',
-						label: i18n.ts.collapseRenotes,
-						keywords: ['renote', i18n.ts.collapseRenotesDescription],
-					},
-					{
-						id: 'igFN7RIUa',
-						label: i18n.ts.showNoteActionsOnlyHover,
-						keywords: ['hover', 'show', 'footer', 'action'],
-					},
-					{
-						id: '9uxocbLO0',
-						label: i18n.ts.showClipButtonInNoteFooter,
-						keywords: ['footer', 'action', 'clip', 'show'],
-					},
-					{
-						id: 'eaT1O1Fao',
-						label: i18n.ts.enableAdvancedMfm,
-						keywords: ['mfm', 'enable', 'show', 'advanced'],
-					},
-					{
-						id: 'omxZk3eET',
-						label: i18n.ts.showReactionsCount,
-						keywords: ['reaction', 'count', 'show'],
-					},
-					{
-						id: 'epvi2Nv2G',
-						label: i18n.ts.loadRawImages,
-						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'],
-					},
-				],
-				label: i18n.ts.note,
-				keywords: ['note'],
-			},
-			{
-				id: 'jb3HUeyrx',
-				children: [
-					{
-						id: 'ykifk3NHS',
-						label: i18n.ts.useGroupedNotifications,
-						keywords: ['group'],
-					},
-				],
-				label: i18n.ts.notifications,
-				keywords: ['notification'],
-			},
-			{
-				id: 'abEAdSpYY',
-				children: [
-					{
-						id: 'lBbtAg0Hm',
-						label: i18n.ts.openImageInNewTab,
-						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'],
-					},
-					{
-						id: 'E9whefUtX',
-						label: i18n.ts.useReactionPickerForContextMenu,
-						keywords: ['reaction', 'picker', 'contextmenu', 'open'],
-					},
-					{
-						id: 'iQaBbJBva',
-						label: i18n.ts.enableInfiniteScroll,
-						keywords: ['load', 'auto', 'more'],
-					},
-					{
-						id: 'hgEVGgJa1',
-						label: i18n.ts.disableStreamingTimeline,
-						keywords: ['disable', 'streaming', 'timeline'],
-					},
-					{
-						id: 'yxehrHZ6x',
-						label: i18n.ts.alwaysConfirmFollow,
-						keywords: ['follow', 'confirm', 'always'],
-					},
-					{
-						id: 'DdoFLaSG8',
-						label: i18n.ts.confirmWhenRevealingSensitiveMedia,
-						keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'],
-					},
-					{
-						id: 'uIMCIK7kG',
-						label: i18n.ts.confirmOnReact,
-						keywords: ['reaction', 'confirm'],
-					},
-					{
-						id: 'zvM13vl26',
-						label: i18n.ts.keepCw,
-						keywords: ['remember', 'keep', 'note', 'cw'],
-					},
-					{
-						id: 'm75VEWI3S',
-						label: i18n.ts.whenServerDisconnected,
-						keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'],
-					},
-					{
-						id: 'bLO9vCyKW',
-						label: i18n.ts.numberOfPageCache,
-						keywords: ['cache', 'page'],
-					},
-					{
-						id: 'iQ7Er89l5',
-						label: i18n.ts.dataSaver,
-						keywords: ['datasaver'],
-					},
-				],
-				label: i18n.ts.behavior,
-				keywords: ['behavior'],
-			},
-			{
-				id: 'C2WYcVM1d',
-				children: [
-					{
-						id: 'Cu7ErCM7C',
-						label: i18n.ts.forceShowAds,
-						keywords: ['ad', 'show'],
-					},
-					{
-						id: 'BBxwy4F6E',
-						label: i18n.ts.hemisphere,
-						keywords: [],
-					},
-					{
-						id: '9YdUwDC8d',
-						label: i18n.ts.additionalEmojiDictionary,
-						keywords: ['emoji', 'dictionary', 'additional', 'extra'],
-					},
-				],
-				label: i18n.ts.other,
-				keywords: [],
-			},
-		],
-		label: i18n.ts.preferences,
-		keywords: ['general', 'preferences'],
-		path: '/settings/preferences',
-		icon: 'ti ti-adjustments',
-	},
-	{
-		id: 'mwkwtw83Y',
-		label: i18n.ts.plugins,
-		keywords: ['plugin'],
-		path: '/settings/plugin',
-		icon: 'ti ti-plug',
-	},
-	{
-		id: 'F1uK9ssiY',
-		children: [
-			{
-				id: 'msAcN6u3S',
-				label: i18n.ts.accountInfo,
-				keywords: ['account', 'info'],
-			},
-			{
-				id: 'ts8DgdnZV',
-				label: i18n.ts.accountMigration,
-				keywords: ['account', 'move', 'migration'],
-			},
-			{
-				id: '4BG7nBECm',
-				label: i18n.ts.closeAccount,
-				keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete],
-			},
-			{
-				id: '2qI6ruPgi',
-				label: i18n.ts.experimentalFeatures,
-				keywords: ['experimental', 'feature', 'flags'],
-			},
-			{
-				id: 'cIeaax47o',
-				label: i18n.ts.developer,
-				keywords: ['developer', 'mode', 'debug'],
-			},
-		],
-		label: i18n.ts.other,
-		keywords: ['other'],
-		path: '/settings/other',
-		icon: 'ti ti-dots',
-	},
-	{
-		id: '3icEvyv2D',
-		children: [
-			{
-				id: 'Tyt3gZTy',
-				children: [
-					{
-						id: '9b7ZURyAt',
-						label: i18n.ts.showMutedWord,
-						keywords: ['show'],
-					},
-				],
-				label: i18n.ts.wordMute,
-				keywords: ['note', 'word', 'soft', 'mute', 'hide'],
-			},
-			{
-				id: 'kdMk41II0',
-				label: i18n.ts.hardWordMute,
-				keywords: ['note', 'word', 'hard', 'mute', 'hide'],
-			},
-			{
-				id: 'mjORQamAK',
-				label: i18n.ts.instanceMute,
-				keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'],
-			},
-			{
-				id: '1ZT7S9FZd',
-				label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`,
-				keywords: ['renote', 'mute', 'hide', 'user'],
-			},
-			{
-				id: 'ANrPit3kQ',
-				label: i18n.ts.mutedUsers,
-				keywords: ['note', 'mute', 'hide', 'user'],
-			},
-			{
-				id: 'bPAE4lfno',
-				label: i18n.ts.blockedUsers,
-				keywords: ['block', 'user'],
-			},
-		],
-		label: i18n.ts.muteAndBlock,
-		keywords: ['mute', 'block'],
-		path: '/settings/mute-block',
-		icon: 'ti ti-ban',
-	},
-	{
-		id: 'qE2vLlMkF',
-		children: [
-			{
-				id: 'hPPEzjvZC',
-				label: i18n.ts._exportOrImport.allNotes,
-				keywords: ['notes'],
-			},
-			{
-				id: 'AFaeHsCUB',
-				label: i18n.ts._exportOrImport.favoritedNotes,
-				keywords: ['favorite', 'notes'],
-			},
-			{
-				id: 'xyCPmQiRo',
-				label: i18n.ts._exportOrImport.clips,
-				keywords: ['clip', 'notes'],
-			},
-			{
-				id: 'Ch7hWAGUy',
-				label: i18n.ts._exportOrImport.followingList,
-				keywords: ['following', 'users'],
-			},
-			{
-				id: 'AwPgFboEx',
-				label: i18n.ts._exportOrImport.userLists,
-				keywords: ['user', 'lists'],
-			},
-			{
-				id: 'nporiHshC',
-				label: i18n.ts._exportOrImport.muteList,
-				keywords: ['mute', 'users'],
-			},
-			{
-				id: 'BsCzR7vNw',
-				label: i18n.ts._exportOrImport.blockingList,
-				keywords: ['block', 'users'],
-			},
-			{
-				id: 'dvf4IgYrQ',
-				label: i18n.ts.antennas,
-				keywords: ['antennas'],
-			},
-		],
-		label: i18n.ts.importAndExport,
-		keywords: ['import', 'export', 'data'],
-		path: '/settings/import-export',
-		icon: 'ti ti-package',
-	},
-	{
-		id: '3Tcxw4Fwl',
-		children: [
-			{
-				id: 'iIai9O65I',
-				label: i18n.ts.emailAddress,
-				keywords: ['email', 'address'],
-			},
-			{
-				id: 'i6cC6oi0m',
-				label: i18n.ts.receiveAnnouncementFromInstance,
-				keywords: ['announcement', 'email'],
-			},
-			{
-				id: 'C1YTinP11',
-				label: i18n.ts.emailNotification,
-				keywords: ['notification', 'email'],
-			},
-		],
-		label: i18n.ts.email,
-		keywords: ['email'],
-		path: '/settings/email',
-		icon: 'ti ti-mail',
-	},
-	{
-		id: 'tnYoppRiv',
-		children: [
-			{
-				id: 'ncIq6TAR2',
-				label: i18n.ts.usageAmount,
-				keywords: ['capacity', 'usage'],
-			},
-			{
-				id: '2c4CQSvSr',
-				label: i18n.ts.statistics,
-				keywords: ['statistics', 'usage'],
-			},
-			{
-				id: 'pepHELHMt',
-				label: i18n.ts.uploadFolder,
-				keywords: ['default', 'upload', 'folder'],
-			},
-			{
-				id: 'xqOWrABxV',
-				label: i18n.ts.keepOriginalUploading,
-				keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription],
-			},
-			{
-				id: 'D8HUTGWE1',
-				label: i18n.ts.keepOriginalFilename,
-				keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription],
-			},
-			{
-				id: '6xAvsWSZi',
-				label: i18n.ts.alwaysMarkSensitive,
-				keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'],
-			},
-			{
-				id: 'csNNPF1KX',
-				label: i18n.ts.enableAutoSensitive,
-				keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription],
-			},
-		],
-		label: i18n.ts.drive,
-		keywords: ['drive'],
-		path: '/settings/drive',
-		icon: 'ti ti-cloud',
-	},
-	{
-		id: 'gtaOSdIJB',
-		label: i18n.ts.avatarDecorations,
-		keywords: ['avatar', 'icon', 'decoration'],
-		path: '/settings/avatar-decoration',
-		icon: 'ti ti-sparkles',
-	},
-	{
-		id: 'AqPvMgn3A',
-		children: [
-			{
-				id: 'j5gTtuMWP',
-				label: i18n.ts.useBlurEffect,
-				keywords: ['blur'],
-			},
-			{
-				id: 'C05WQNSIJ',
-				label: i18n.ts.useBlurEffectForModal,
-				keywords: ['blur', 'modal'],
-			},
-			{
-				id: 'snVKNr7Bw',
-				label: i18n.ts.highlightSensitiveMedia,
-				keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'],
-			},
-			{
-				id: 'DsS2CwjYE',
-				label: i18n.ts.squareAvatars,
-				keywords: ['avatar', 'icon', 'square'],
-			},
-			{
-				id: 'xCcTDl651',
-				label: i18n.ts.showAvatarDecorations,
-				keywords: ['avatar', 'icon', 'decoration', 'show'],
-			},
-			{
-				id: '3dHw723VD',
-				label: i18n.ts.showGapBetweenNotesInTimeline,
-				keywords: ['note', 'timeline', 'gap'],
-			},
-			{
-				id: 'AWi72xbrl',
-				label: i18n.ts.seasonalScreenEffect,
-				keywords: ['effect', 'show'],
-			},
-			{
-				id: 'Ces8FsJws',
-				label: i18n.ts.menuStyle,
-				keywords: ['menu', 'style', 'popup', 'drawer'],
-			},
-			{
-				id: 'wDr9xSXCv',
-				label: i18n.ts.emojiStyle,
-				keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'],
-			},
-			{
-				id: 'vFB0pLzck',
-				label: i18n.ts.fontSize,
-				keywords: ['font', 'size'],
-			},
-			{
-				id: '23BhvYXPC',
-				label: i18n.ts.useSystemFont,
-				keywords: ['font', 'system', 'native'],
-			},
-			{
-				id: 'EeNLndAOa',
-				children: [
-					{
-						id: 'rAAPoaodS',
-						label: i18n.ts.reactionsDisplaySize,
-						keywords: ['reaction', 'size', 'scale', 'display'],
-					},
-					{
-						id: 'qTLAvNWsc',
-						label: i18n.ts.limitWidthOfReaction,
-						keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'],
-					},
-					{
-						id: '2lWgzAm13',
-						label: i18n.ts.mediaListWithOneImageAppearance,
-						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'],
-					},
-					{
-						id: 'EU7HbxOR5',
-						label: i18n.ts.instanceTicker,
-						keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'],
-					},
-					{
-						id: 'AEtM0FAp1',
-						label: i18n.ts.displayOfSensitiveMedia,
-						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'],
-					},
-				],
-				label: i18n.ts.displayOfNote,
-				keywords: ['note', 'display'],
-			},
-			{
-				id: 'A1FMC2Zon',
-				children: [
-					{
-						id: 'CB37G5ZDo',
-						label: i18n.ts.position,
-						keywords: ['position'],
-					},
-					{
-						id: 'gGS2i19hS',
-						label: i18n.ts.stackAxis,
-						keywords: ['stack', 'axis', 'direction'],
-					},
-				],
-				label: i18n.ts.notificationDisplay,
-				keywords: ['notification', 'display'],
-			},
-		],
-		label: i18n.ts.appearance,
-		keywords: ['appearance'],
-		path: '/settings/appearance',
-		icon: 'ti ti-device-desktop',
-	},
-	{
-		id: 'f08Mi1Uwn',
-		children: [
-			{
-				id: '7ov7ceoij',
-				label: i18n.ts.reduceUiAnimation,
-				keywords: ['animation', 'motion', 'reduce'],
-			},
-			{
-				id: 'cXr3tFdpa',
-				label: i18n.ts.disableShowingAnimatedImages,
-				keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'],
-			},
-			{
-				id: 'Ok1UBwtP',
-				label: i18n.ts.enableAnimatedMfm,
-				keywords: ['mfm', 'enable', 'show', 'animated'],
-			},
-			{
-				id: 'yPEpJigqY',
-				label: i18n.ts.enableHorizontalSwipe,
-				keywords: ['swipe', 'horizontal', 'tab'],
-			},
-			{
-				id: 'h7iZtdTU3',
-				label: i18n.ts.keepScreenOn,
-				keywords: ['keep', 'screen', 'display', 'on'],
-			},
-			{
-				id: 'gP1BY3PDy',
-				label: i18n.ts.useNativeUIForVideoAudioPlayer,
-				keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
-			},
-			{
-				id: 'jnMK3M6rs',
-				label: i18n.ts._contextMenu.title,
-				keywords: ['contextmenu', 'system', 'native'],
-			},
-		],
-		label: i18n.ts.accessibility,
-		keywords: ['accessibility'],
-		path: '/settings/accessibility',
-		icon: 'ti ti-accessible',
-	},
-] as const;
-
-export type SearchIndex = typeof searchIndexes;
diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/scripts/cache.ts
deleted file mode 100644
index 0fbdf34d5d..0000000000
--- a/packages/frontend/src/scripts/cache.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-
-export class Cache<T> {
-	private cachedAt: number | null = null;
-	public value = ref<T | undefined>();
-	private lifetime: number;
-	private fetcher: () => Promise<T>;
-
-	constructor(lifetime: Cache<never>['lifetime'], fetcher: () => Promise<T>) {
-		this.lifetime = lifetime;
-		this.fetcher = fetcher;
-	}
-
-	public set(value: T): void {
-		this.cachedAt = Date.now();
-		this.value.value = value;
-	}
-
-	private get(): T | undefined {
-		if (this.cachedAt == null) return undefined;
-		if ((Date.now() - this.cachedAt) > this.lifetime) {
-			this.value.value = undefined;
-			this.cachedAt = null;
-			return undefined;
-		}
-		return this.value.value;
-	}
-
-	public delete() {
-		this.cachedAt = null;
-	}
-
-	/**
-	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
-	 */
-	public async fetch(): Promise<T> {
-		const cachedValue = this.get();
-		if (cachedValue !== undefined) {
-			// Cache HIT
-			return cachedValue;
-		}
-
-		// Cache MISS
-		const value = await this.fetcher();
-		this.set(value);
-		return value;
-	}
-}
diff --git a/packages/frontend/src/scripts/chart-legend.ts b/packages/frontend/src/scripts/chart-legend.ts
deleted file mode 100644
index e701d18dd2..0000000000
--- a/packages/frontend/src/scripts/chart-legend.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Plugin } from 'chart.js';
-import MkChartLegend from '@/components/MkChartLegend.vue';
-
-export const chartLegend = (legend: InstanceType<typeof MkChartLegend>) => ({
-	id: 'htmlLegend',
-	afterUpdate(chart, args, options) {
-		// Reuse the built-in legendItems generator
-		const items = chart.options.plugins.legend.labels.generateLabels(chart);
-
-		legend.update(chart, items);
-	},
-}) as Plugin;
diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts
deleted file mode 100644
index 465ca591c6..0000000000
--- a/packages/frontend/src/scripts/chart-vline.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Plugin } from 'chart.js';
-
-export const chartVLine = (vLineColor: string) => ({
-	id: 'vLine',
-	beforeDraw(chart, args, options) {
-		if (chart.tooltip?._active?.length) {
-			const ctx = chart.ctx;
-			const xs = chart.tooltip._active.map(a => a.element.x);
-			const x = xs.reduce((a, b) => a + b, 0) / xs.length;
-			const topY = chart.scales.y.top;
-			const bottomY = chart.scales.y.bottom;
-
-			ctx.save();
-			ctx.beginPath();
-			ctx.moveTo(x, bottomY);
-			ctx.lineTo(x, topY);
-			ctx.lineWidth = 1;
-			ctx.strokeStyle = vLineColor;
-			ctx.stroke();
-			ctx.restore();
-		}
-	},
-}) as Plugin;
diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/scripts/check-permissions.ts
deleted file mode 100644
index ed86529d5b..0000000000
--- a/packages/frontend/src/scripts/check-permissions.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { instance } from '@/instance.js';
-import { $i } from '@/account.js';
-
-export const notesSearchAvailable = (
-	// FIXME: instance.policies would be null in Vitest
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	($i == null && instance.policies != null && instance.policies.canSearchNotes) ||
-	($i != null && $i.policies.canSearchNotes) ||
-	false
-) as boolean;
-
-export const canSearchNonLocalNotes = (
-	instance.noteSearchableScope === 'global'
-);
diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts
deleted file mode 100644
index 281ea2520e..0000000000
--- a/packages/frontend/src/scripts/check-reaction-permissions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import type { UnicodeEmojiDef } from '@@/js/emojilist.js';
-
-export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
-	if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする;
-	if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
-
-	const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
-	return !(emoji.localOnly && note.user.host !== me.host)
-      && !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
-      && (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id)));
-}
diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts
deleted file mode 100644
index 98fea1bced..0000000000
--- a/packages/frontend/src/scripts/check-word-mute.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import * as Misskey from 'misskey-js';
-
-export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
-	// 自分自身
-	if (me && (note.userId === me.id)) return false;
-
-	if (mutedWords.length > 0) {
-		const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
-
-		if (text === '') return false;
-
-		const matched = mutedWords.filter(filter => {
-			if (Array.isArray(filter)) {
-				// Clean up
-				const filteredFilter = filter.filter(keyword => keyword !== '');
-				if (filteredFilter.length === 0) return false;
-
-				return filteredFilter.every(keyword => text.includes(keyword));
-			} else {
-				// represents RegExp
-				const regexp = filter.match(/^\/(.+)\/(.*)$/);
-
-				// This should never happen due to input sanitisation.
-				if (!regexp) return false;
-
-				try {
-					return new RegExp(regexp[1], regexp[2]).test(text);
-				} catch (err) {
-					// This should never happen due to input sanitisation.
-					return false;
-				}
-			}
-		});
-
-		if (matched.length > 0) return matched;
-	}
-
-	return false;
-}
diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/scripts/clear-cache.ts
deleted file mode 100644
index 71d1232710..0000000000
--- a/packages/frontend/src/scripts/clear-cache.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { unisonReload } from '@/scripts/unison-reload.js';
-import * as os from '@/os.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { fetchCustomEmojis } from '@/custom-emojis.js';
-import { fetchInstance } from '@/instance.js';
-
-export async function clearCache() {
-	os.waiting();
-	miLocalStorage.removeItem('instance');
-	miLocalStorage.removeItem('instanceCachedAt');
-	miLocalStorage.removeItem('locale');
-	miLocalStorage.removeItem('localeVersion');
-	miLocalStorage.removeItem('theme');
-	miLocalStorage.removeItem('emojis');
-	miLocalStorage.removeItem('lastEmojisFetchedAt');
-	await fetchInstance(true);
-	await fetchCustomEmojis(true);
-	unisonReload();
-}
diff --git a/packages/frontend/src/scripts/clicker-game.ts b/packages/frontend/src/scripts/clicker-game.ts
deleted file mode 100644
index f9c4bc1829..0000000000
--- a/packages/frontend/src/scripts/clicker-game.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref, computed } from 'vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-
-type SaveData = {
-	gameVersion: number;
-	cookies: number;
-	totalCookies: number;
-	totalHandmadeCookies: number;
-	clicked: number;
-	achievements: any[];
-	facilities: any[];
-};
-
-export const saveData = ref<SaveData>();
-export const ready = computed(() => saveData.value != null);
-
-let prev = '';
-
-export async function load() {
-	try {
-		saveData.value = await misskeyApi('i/registry/get', {
-			scope: ['clickerGame'],
-			key: 'saveData',
-		});
-	} catch (err) {
-		if (err.code === 'NO_SUCH_KEY') {
-			saveData.value = {
-				gameVersion: 2,
-				cookies: 0,
-				totalCookies: 0,
-				totalHandmadeCookies: 0,
-				clicked: 0,
-				achievements: [],
-				facilities: [],
-			};
-			save();
-			return;
-		}
-		throw err;
-	}
-
-	// migration
-	if (saveData.value.gameVersion === 1) {
-		saveData.value = {
-			gameVersion: 2,
-			cookies: saveData.value.cookies,
-			totalCookies: saveData.value.cookies,
-			totalHandmadeCookies: saveData.value.cookies,
-			clicked: saveData.value.clicked,
-			achievements: [],
-			facilities: [],
-		};
-		save();
-	}
-}
-
-export async function save() {
-	const current = JSON.stringify(saveData.value);
-	if (current === prev) return;
-
-	await misskeyApi('i/registry/set', {
-		scope: ['clickerGame'],
-		key: 'saveData',
-		value: saveData.value,
-	});
-
-	prev = current;
-}
diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts
deleted file mode 100644
index ea8eea14b5..0000000000
--- a/packages/frontend/src/scripts/clone.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// structredCloneが遅いため
-// SEE: http://var.blog.jp/archives/86038606.html
-// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
-// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
-
-export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
-
-export function deepClone<T extends Cloneable>(x: T): T {
-	if (typeof x === 'object') {
-		if (x === null) return x;
-		if (Array.isArray(x)) return x.map(deepClone) as T;
-		const obj = {} as Record<string | number | symbol, Cloneable>;
-		for (const [k, v] of Object.entries(x)) {
-			obj[k] = v === undefined ? undefined : deepClone(v);
-		}
-		return obj as T;
-	} else {
-		return x;
-	}
-}
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
deleted file mode 100644
index 4f2aff9d4c..0000000000
--- a/packages/frontend/src/scripts/code-highlighter.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { createHighlighterCore } from 'shiki/core';
-import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
-import darkPlus from 'shiki/themes/dark-plus.mjs';
-import { bundledThemesInfo } from 'shiki/themes';
-import { bundledLanguagesInfo } from 'shiki/langs';
-import lightTheme from '@@/themes/_light.json5';
-import darkTheme from '@@/themes/_dark.json5';
-import defaultLightTheme from '@@/themes/l-light.json5';
-import defaultDarkTheme from '@@/themes/d-green-lime.json5';
-import { unique } from './array.js';
-import { deepClone } from './clone.js';
-import { deepMerge } from './merge.js';
-import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
-import { prefer } from '@/preferences.js';
-
-let _highlighter: HighlighterCore | null = null;
-
-export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
-export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
-export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> {
-	const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme);
-
-	if (theme.base) {
-		const base = [lightTheme, darkTheme].find(x => x.id === theme.base);
-		if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter);
-	}
-
-	if (theme.codeHighlighter) {
-		let _res: ThemeRegistration = {};
-		if (theme.codeHighlighter.base === '_none_') {
-			_res = deepClone(theme.codeHighlighter.overrides);
-		} else {
-			const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus;
-			_res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base);
-		}
-		if (_res.name == null) {
-			_res.name = theme.id;
-		}
-		_res.type = mode;
-
-		if (getName) {
-			return _res.name;
-		}
-		return _res;
-	}
-
-	if (getName) {
-		return 'dark-plus';
-	}
-	return darkPlus;
-}
-
-export async function getHighlighter(): Promise<HighlighterCore> {
-	if (!_highlighter) {
-		return await initHighlighter();
-	}
-	return _highlighter;
-}
-
-async function initHighlighter() {
-	// テーマの重複を消す
-	const themes = unique([
-		darkPlus,
-		...(await Promise.all([getTheme('light'), getTheme('dark')])),
-	]);
-
-	const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
-	const highlighter = await createHighlighterCore({
-		engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')),
-		themes,
-		langs: [
-			...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
-			async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
-		],
-	});
-
-	// TODO
-	//watch('lightTheme', async () => {
-	//	const newTheme = await getTheme('light');
-	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
-	//		highlighter.loadTheme(newTheme);
-	//	}
-	//});
-	//watch('darkTheme', async () => {
-	//	const newTheme = await getTheme('dark');
-	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
-	//		highlighter.loadTheme(newTheme);
-	//	}
-	//});
-
-	_highlighter = highlighter;
-
-	return highlighter;
-}
diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts
deleted file mode 100644
index 5096c0669e..0000000000
--- a/packages/frontend/src/scripts/collect-page-vars.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-interface StringPageVar {
-	name: string,
-	type: 'string',
-	value: string
-}
-
-interface NumberPageVar {
-	name: string,
-	type: 'number',
-	value: number
-}
-
-interface BooleanPageVar {
-	name: string,
-	type: 'boolean',
-	value: boolean
-}
-
-type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
-
-export function collectPageVars(content): PageVar[] {
-	const pageVars: PageVar[] = [];
-	const collect = (xs: any[]): void => {
-		for (const x of xs) {
-			if (x.type === 'textInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.type === 'textareaInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.type === 'numberInput') {
-				pageVars.push({
-					name: x.name,
-					type: 'number',
-					value: x.default || 0,
-				});
-			} else if (x.type === 'switch') {
-				pageVars.push({
-					name: x.name,
-					type: 'boolean',
-					value: x.default || false,
-				});
-			} else if (x.type === 'counter') {
-				pageVars.push({
-					name: x.name,
-					type: 'number',
-					value: 0,
-				});
-			} else if (x.type === 'radioButton') {
-				pageVars.push({
-					name: x.name,
-					type: 'string',
-					value: x.default || '',
-				});
-			} else if (x.children) {
-				collect(x.children);
-			}
-		}
-	};
-	collect(content);
-	return pageVars;
-}
diff --git a/packages/frontend/src/scripts/color.ts b/packages/frontend/src/scripts/color.ts
deleted file mode 100644
index a11255ffd1..0000000000
--- a/packages/frontend/src/scripts/color.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const alpha = (hex: string, a: number): string => {
-	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
-	const r = parseInt(result[1], 16);
-	const g = parseInt(result[2], 16);
-	const b = parseInt(result[3], 16);
-	return `rgba(${r}, ${g}, ${b}, ${a})`;
-};
diff --git a/packages/frontend/src/scripts/confetti.ts b/packages/frontend/src/scripts/confetti.ts
deleted file mode 100644
index 8e53a6ceeb..0000000000
--- a/packages/frontend/src/scripts/confetti.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import _confetti from 'canvas-confetti';
-import * as os from '@/os.js';
-
-export function confetti(options: { duration?: number; } = {}) {
-	const duration = options.duration ?? 1000 * 4;
-	const animationEnd = Date.now() + duration;
-	const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: os.claimZIndex('high') };
-
-	function randomInRange(min, max) {
-		return Math.random() * (max - min) + min;
-	}
-
-	const interval = setInterval(() => {
-		const timeLeft = animationEnd - Date.now();
-
-		if (timeLeft <= 0) {
-			return clearInterval(interval);
-		}
-
-		const particleCount = 50 * (timeLeft / duration);
-		// since particles fall down, start a bit higher than random
-		_confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }));
-		_confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }));
-	}, 250);
-}
diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts
deleted file mode 100644
index 6137c06e85..0000000000
--- a/packages/frontend/src/scripts/contains.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export default (parent, child, checkSame = true) => {
-	if (checkSame && parent === child) return true;
-	let node = child.parentNode;
-	while (node) {
-		if (node === parent) return true;
-		node = node.parentNode;
-	}
-	return false;
-};
diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts
deleted file mode 100644
index 7e0bb25606..0000000000
--- a/packages/frontend/src/scripts/copy-to-clipboard.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * Clipboardに値をコピー(TODO: 文字列以外も対応)
- */
-export function copyToClipboard(input: string | null) {
-	if (input) navigator.clipboard.writeText(input);
-};
diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts
deleted file mode 100644
index 7aadb617ca..0000000000
--- a/packages/frontend/src/scripts/device-kind.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export type DeviceKind = 'smartphone' | 'tablet' | 'desktop';
-
-const ua = navigator.userAgent.toLowerCase();
-const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
-const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
-
-export const DEFAULT_DEVICE_KIND: DeviceKind = (
-	isSmartphone
-		? 'smartphone'
-		: isTablet
-			? 'tablet'
-			: 'desktop'
-);
-
-export let deviceKind: DeviceKind = DEFAULT_DEVICE_KIND;
-
-export function updateDeviceKind(kind: DeviceKind | null) {
-	deviceKind = kind ?? DEFAULT_DEVICE_KIND;
-}
diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/scripts/emoji-picker.ts
deleted file mode 100644
index 7ff5863243..0000000000
--- a/packages/frontend/src/scripts/emoji-picker.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent, ref } from 'vue';
-import type { Ref } from 'vue';
-import { popup } from '@/os.js';
-import { store } from '@/store.js';
-
-/**
- * 絵文字ピッカーを表示する。
- * 類似の機能として{@link ReactionPicker}が存在しているが、この機能とは動きが異なる。
- * 投稿フォームなどで絵文字を選択する時など、絵文字ピックアップ後でもダイアログが消えずに残り、
- * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。
- */
-class EmojiPicker {
-	private src: Ref<HTMLElement | null> = ref(null);
-	private manualShowing = ref(false);
-	private onChosen?: (emoji: string) => void;
-	private onClosed?: () => void;
-
-	constructor() {
-		// nop
-	}
-
-	public async init() {
-		const emojisRef = store.reactiveState.pinnedEmojis;
-		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
-			src: this.src,
-			pinnedEmojis: emojisRef,
-			asReactionPicker: false,
-			manualShowing: this.manualShowing,
-			choseAndClose: false,
-		}, {
-			done: emoji => {
-				if (this.onChosen) this.onChosen(emoji);
-			},
-			close: () => {
-				this.manualShowing.value = false;
-			},
-			closed: () => {
-				this.src.value = null;
-				if (this.onClosed) this.onClosed();
-			},
-		});
-	}
-
-	public show(
-		src: HTMLElement,
-		onChosen?: EmojiPicker['onChosen'],
-		onClosed?: EmojiPicker['onClosed'],
-	) {
-		this.src.value = src;
-		this.manualShowing.value = true;
-		this.onChosen = onChosen;
-		this.onClosed = onClosed;
-	}
-}
-
-export const emojiPicker = new EmojiPicker();
diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts
deleted file mode 100644
index d518562053..0000000000
--- a/packages/frontend/src/scripts/extract-mentions.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// test is located in test/extract-mentions
-
-import * as mfm from 'mfm-js';
-
-export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
-	// TODO: 重複を削除
-	const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
-	const mentions = mentionNodes.map(x => x.props);
-
-	return mentions;
-}
diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts
deleted file mode 100644
index d5654ba850..0000000000
--- a/packages/frontend/src/scripts/extract-url-from-mfm.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as mfm from 'mfm-js';
-import { unique } from '@/scripts/array.js';
-
-// unique without hash
-// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
-const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
-
-export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
-	const urlNodes = mfm.extract(nodes, (node) => {
-		return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
-	});
-	const urls: string[] = unique(urlNodes.map(x => x.props.url));
-
-	return urls.reduce((array, url) => {
-		const urlWithoutHash = removeHash(url);
-		if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
-		return array;
-	}, [] as string[]);
-}
diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts
deleted file mode 100644
index 4259fe25e9..0000000000
--- a/packages/frontend/src/scripts/file-drop.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export type DroppedItem = DroppedFile | DroppedDirectory;
-
-export type DroppedFile = {
-	isFile: true;
-	path: string;
-	file: File;
-};
-
-export type DroppedDirectory = {
-	isFile: false;
-	path: string;
-	children: DroppedItem[];
-};
-
-export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> {
-	const dropItems = ev.dataTransfer?.items;
-	if (!dropItems || dropItems.length === 0) {
-		return [];
-	}
-
-	const apiTestItem = dropItems[0];
-	if ('webkitGetAsEntry' in apiTestItem) {
-		return readDataTransferItems(dropItems);
-	} else {
-		// webkitGetAsEntryに対応していない場合はfilesから取得する（ディレクトリのサポートは出来ない）
-		const dropFiles = ev.dataTransfer.files;
-		if (dropFiles.length === 0) {
-			return [];
-		}
-
-		const droppedFiles = Array.of<DroppedFile>();
-		for (let i = 0; i < dropFiles.length; i++) {
-			const file = dropFiles.item(i);
-			if (file) {
-				droppedFiles.push({
-					isFile: true,
-					path: file.name,
-					file,
-				});
-			}
-		}
-
-		return droppedFiles;
-	}
-}
-
-/**
- * ドラッグ＆ドロップされたファイルのリストからディレクトリ構造とファイルへの参照（{@link File}）を取得する。
- */
-export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
-	async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
-		if (entry.isFile) {
-			return {
-				isFile: true,
-				path: entry.fullPath,
-				file: await readFile(entry as FileSystemFileEntry),
-			};
-		} else {
-			return {
-				isFile: false,
-				path: entry.fullPath,
-				children: await readDirectory(entry as FileSystemDirectoryEntry),
-			};
-		}
-	}
-
-	function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
-		return new Promise((resolve, reject) => {
-			fileSystemFileEntry.file(resolve, reject);
-		});
-	}
-
-	function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
-		return new Promise(async (resolve) => {
-			const allEntries = Array.of<FileSystemEntry>();
-			const reader = fileSystemDirectoryEntry.createReader();
-			while (true) {
-				const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej));
-				if (entries.length === 0) {
-					break;
-				}
-				allEntries.push(...entries);
-			}
-
-			resolve(await Promise.all(allEntries.map(readEntry)));
-		});
-	}
-
-	// 扱いにくいので配列に変換
-	const items = Array.of<DataTransferItem>();
-	for (let i = 0; i < itemList.length; i++) {
-		items.push(itemList[i]);
-	}
-
-	return Promise.all(
-		items
-			.map(it => it.webkitGetAsEntry())
-			.filter(it => it)
-			.map(it => readEntry(it!)),
-	);
-}
-
-/**
- * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。
- */
-export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] {
-	const result = Array.of<DroppedFile>();
-	for (const item of items) {
-		if (item.isFile) {
-			result.push(item);
-		} else {
-			result.push(...flattenDroppedFiles(item.children));
-		}
-	}
-	return result;
-}
diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts
deleted file mode 100644
index fb7caea830..0000000000
--- a/packages/frontend/src/scripts/focus-trap.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
-
-const focusTrapElements = new Set<HTMLElement>();
-const ignoreElements = [
-	'script',
-	'style',
-];
-
-function containsFocusTrappedElements(el: HTMLElement): boolean {
-	return Array.from(focusTrapElements).some((focusTrapElement) => {
-		return el.contains(focusTrapElement);
-	});
-}
-
-function getZIndex(el: HTMLElement): number {
-	const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10);
-	if (isNaN(zIndex)) {
-		return 0;
-	}
-	return zIndex;
-}
-
-function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null {
-	let highestZIndexElement: HTMLElement | null = null;
-	let highestZIndex = -Infinity;
-
-	focusTrapElements.forEach((el) => {
-		const zIndex = getZIndex(el);
-		if (zIndex > highestZIndex) {
-			highestZIndex = zIndex;
-			highestZIndexElement = el;
-		}
-	});
-
-	return highestZIndexElement == null ? null : {
-		el: highestZIndexElement,
-		zIndex: highestZIndex,
-	};
-}
-
-function releaseFocusTrap(el: HTMLElement): void {
-	focusTrapElements.delete(el);
-	if (el.inert === true) {
-		el.inert = false;
-	}
-
-	const highestZIndexElement = getHighestZIndexElement();
-
-	if (el.parentElement != null && el !== document.body) {
-		el.parentElement.childNodes.forEach((siblingNode) => {
-			const siblingEl = getHTMLElementOrNull(siblingNode);
-			if (!siblingEl) return;
-			if (
-				siblingEl !== el &&
-				(
-					highestZIndexElement == null ||
-					siblingEl === highestZIndexElement.el ||
-					siblingEl.contains(highestZIndexElement.el)
-				)
-			) {
-				siblingEl.inert = false;
-			} else if (
-				highestZIndexElement != null &&
-				siblingEl !== highestZIndexElement.el &&
-				!siblingEl.contains(highestZIndexElement.el) &&
-				!ignoreElements.includes(siblingEl.tagName.toLowerCase())
-			) {
-				siblingEl.inert = true;
-			} else {
-				siblingEl.inert = false;
-			}
-		});
-		releaseFocusTrap(el.parentElement);
-	}
-}
-
-export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void;
-export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; };
-export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void {
-	const highestZIndexElement = getHighestZIndexElement();
-
-	const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex;
-	const zIndex = getZIndex(el);
-
-	// If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead
-	// Focus trapping for this element will be done in the release function
-	if (!parent && zIndex < highestZIndex) {
-		focusTrapElements.add(el);
-		if (highestZIndexElement) {
-			focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls);
-		}
-		return {
-			release: () => {
-				releaseFocusTrap(el);
-			},
-		};
-	}
-
-	if (el.inert === true) {
-		el.inert = false;
-	}
-
-	if (el.parentElement != null && el !== document.body) {
-		el.parentElement.childNodes.forEach((siblingNode) => {
-			const siblingEl = getHTMLElementOrNull(siblingNode);
-			if (!siblingEl) return;
-			if (
-				siblingEl !== el &&
-				(
-					hasInteractionWithOtherFocusTrappedEls === false ||
-					(!focusTrapElements.has(siblingEl) && !containsFocusTrappedElements(siblingEl))
-				) &&
-				!ignoreElements.includes(siblingEl.tagName.toLowerCase())
-			) {
-				siblingEl.inert = true;
-			}
-		});
-		focusTrap(el.parentElement, hasInteractionWithOtherFocusTrappedEls, true);
-	}
-
-	if (!parent) {
-		focusTrapElements.add(el);
-
-		return {
-			release: () => {
-				releaseFocusTrap(el);
-			},
-		};
-	}
-}
diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts
deleted file mode 100644
index 81278b17ea..0000000000
--- a/packages/frontend/src/scripts/focus.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js';
-import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
-
-type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;
-
-export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => {
-	if (input == null || !(input instanceof HTMLElement)) return false;
-
-	if (input.tabIndex < 0) return false;
-	if ('disabled' in input && input.disabled === true) return false;
-	if ('readonly' in input && input.readonly === true) return false;
-
-	if (!input.ownerDocument.contains(input)) return false;
-
-	const style = window.getComputedStyle(input);
-	if (style.display === 'none') return false;
-	if (style.visibility === 'hidden') return false;
-	if (style.opacity === '0') return false;
-	if (style.pointerEvents === 'none') return false;
-
-	return true;
-};
-
-export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
-	const element = self ? input : getElementOrNull(input)?.previousElementSibling;
-	if (element == null) return;
-	if (isFocusable(element)) {
-		focusOrScroll(element, scroll);
-	} else {
-		focusPrev(element, false, scroll);
-	}
-};
-
-export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
-	const element = self ? input : getElementOrNull(input)?.nextElementSibling;
-	if (element == null) return;
-	if (isFocusable(element)) {
-		focusOrScroll(element, scroll);
-	} else {
-		focusNext(element, false, scroll);
-	}
-};
-
-export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
-	const element = self ? input : getNodeOrNull(input)?.parentElement;
-	if (element == null) return;
-	if (isFocusable(element)) {
-		focusOrScroll(element, scroll);
-	} else {
-		focusParent(element, false, scroll);
-	}
-};
-
-const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
-	if (scroll) {
-		const scrollContainer = getScrollContainer(element) ?? document.documentElement;
-		const scrollContainerTop = getScrollPosition(scrollContainer);
-		const stickyTop = getStickyTop(element, scrollContainer);
-		const stickyBottom = getStickyBottom(element, scrollContainer);
-		const top = element.getBoundingClientRect().top;
-		const bottom = element.getBoundingClientRect().bottom;
-
-		let scrollTo = scrollContainerTop;
-		if (top < stickyTop) {
-			scrollTo += top - stickyTop;
-		} else if (bottom > window.innerHeight - stickyBottom) {
-			scrollTo += bottom - window.innerHeight + stickyBottom;
-		}
-		scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' });
-	}
-
-	if (document.activeElement !== element) {
-		element.focus({ preventScroll: true });
-	}
-};
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
deleted file mode 100644
index 1032e97ac9..0000000000
--- a/packages/frontend/src/scripts/form.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-
-type EnumItem = string | {
-	label: string;
-	value: string;
-};
-
-type Hidden = boolean | ((v: any) => boolean);
-
-export type FormItem = {
-	label?: string;
-	type: 'string';
-	default?: string | null;
-	description?: string;
-	required?: boolean;
-	hidden?: Hidden;
-	multiline?: boolean;
-	treatAsMfm?: boolean;
-} | {
-	label?: string;
-	type: 'number';
-	default?: number | null;
-	description?: string;
-	required?: boolean;
-	hidden?: Hidden;
-	step?: number;
-} | {
-	label?: string;
-	type: 'boolean';
-	default?: boolean | null;
-	description?: string;
-	hidden?: Hidden;
-} | {
-	label?: string;
-	type: 'enum';
-	default?: string | null;
-	required?: boolean;
-	hidden?: Hidden;
-	enum: EnumItem[];
-} | {
-	label?: string;
-	type: 'radio';
-	default?: unknown | null;
-	required?: boolean;
-	hidden?: Hidden;
-	options: {
-		label: string;
-		value: unknown;
-	}[];
-} | {
-	label?: string;
-	type: 'range';
-	default?: number | null;
-	description?: string;
-	required?: boolean;
-	step?: number;
-	min: number;
-	max: number;
-	textConverter?: (value: number) => string;
-	hidden?: Hidden;
-} | {
-	label?: string;
-	type: 'object';
-	default?: Record<string, unknown> | null;
-	hidden: Hidden;
-} | {
-	label?: string;
-	type: 'array';
-	default?: unknown[] | null;
-	hidden: Hidden;
-} | {
-	type: 'button';
-	content?: string;
-	hidden?: Hidden;
-	action: (ev: MouseEvent, v: any) => void;
-} | {
-	type: 'drive-file';
-	defaultFileId?: string | null;
-	hidden?: Hidden;
-	validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>;
-};
-
-export type Form = Record<string, FormItem>;
-
-type GetItemType<Item extends FormItem> =
-	Item['type'] extends 'string' ? string :
-	Item['type'] extends 'number' ? number :
-	Item['type'] extends 'boolean' ? boolean :
-	Item['type'] extends 'radio' ? unknown :
-	Item['type'] extends 'range' ? number :
-	Item['type'] extends 'enum' ? string :
-	Item['type'] extends 'array' ? unknown[] :
-	Item['type'] extends 'object' ? Record<string, unknown> :
-	Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined :
-	never;
-
-export type GetFormResultType<F extends Form> = {
-	[P in keyof F]: GetItemType<F[P]>;
-};
diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts
deleted file mode 100644
index d383f143e1..0000000000
--- a/packages/frontend/src/scripts/format-time-string.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-const defaultLocaleStringFormats: { [index: string]: string } = {
-	'weekday': 'narrow',
-	'era': 'narrow',
-	'year': 'numeric',
-	'month': 'numeric',
-	'day': 'numeric',
-	'hour': 'numeric',
-	'minute': 'numeric',
-	'second': 'numeric',
-	'timeZoneName': 'short',
-};
-
-function formatLocaleString(date: Date, format: string): string {
-	return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
-		if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
-			return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] });
-		} else {
-			return match;
-		}
-	});
-}
-
-export function formatDateTimeString(date: Date, format: string): string {
-	return format
-		.replace(/yyyy/g, date.getFullYear().toString())
-		.replace(/yy/g, date.getFullYear().toString().slice(-2))
-		.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' }))
-		.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' }))
-		.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
-		.replace(/M/g, (date.getMonth() + 1).toString())
-		.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
-		.replace(/d/g, date.getDate().toString())
-		.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
-		.replace(/H/g, date.getHours().toString())
-		.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
-		.replace(/h/g, ((date.getHours() % 12) || 12).toString())
-		.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
-		.replace(/m/g, date.getMinutes().toString())
-		.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
-		.replace(/s/g, date.getSeconds().toString())
-		.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
-}
-
-export function formatTimeString(date: Date, format: string): string {
-	return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
-		if (localeformat) return formatLocaleString(date, localeformat);
-		if (datetimeformat) return formatDateTimeString(date, datetimeformat);
-		return match;
-	});
-}
diff --git a/packages/frontend/src/scripts/fullscreen.ts b/packages/frontend/src/scripts/fullscreen.ts
deleted file mode 100644
index 7a0a018ef3..0000000000
--- a/packages/frontend/src/scripts/fullscreen.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-type PartiallyPartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
-
-type VideoEl = PartiallyPartial<HTMLVideoElement, 'requestFullscreen'> & {
-	webkitEnterFullscreen?(): void;
-	webkitExitFullscreen?(): void;
-};
-
-type PlayerEl = PartiallyPartial<HTMLElement, 'requestFullscreen'>;
-
-type RequestFullscreenProps = {
-	readonly videoEl: VideoEl;
-	readonly playerEl: PlayerEl;
-	readonly options?: FullscreenOptions | null;
-};
-
-type ExitFullscreenProps = {
-	readonly videoEl: VideoEl;
-};
-
-export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscreenProps) => {
-	if (playerEl.requestFullscreen != null) {
-		playerEl.requestFullscreen(options ?? undefined);
-		return;
-	}
-	if (videoEl.webkitEnterFullscreen != null) {
-		videoEl.webkitEnterFullscreen();
-		return;
-	}
-};
-
-export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => {
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	if (document.exitFullscreen != null) {
-		document.exitFullscreen();
-		return;
-	}
-	if (videoEl.webkitExitFullscreen != null) {
-		videoEl.webkitExitFullscreen();
-		return;
-	}
-};
diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts
deleted file mode 100644
index 40afa10f2d..0000000000
--- a/packages/frontend/src/scripts/get-account-from-id.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { get } from '@/scripts/idb-proxy.js';
-
-export async function getAccountFromId(id: string) {
-	const accounts = await get('accounts') as { token: string; id: string; }[];
-	if (!accounts) console.log('Accounts are not recorded');
-	return accounts.find(account => account.id === id);
-}
diff --git a/packages/frontend/src/scripts/get-appear-note.ts b/packages/frontend/src/scripts/get-appear-note.ts
deleted file mode 100644
index 40ce80eac9..0000000000
--- a/packages/frontend/src/scripts/get-appear-note.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-
-export function getAppearNote(note: Misskey.entities.Note) {
-	return Misskey.note.isPureRenote(note) ? note.renote : note;
-}
diff --git a/packages/frontend/src/scripts/get-bg-color.ts b/packages/frontend/src/scripts/get-bg-color.ts
deleted file mode 100644
index ccf60b454f..0000000000
--- a/packages/frontend/src/scripts/get-bg-color.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import tinycolor from 'tinycolor2';
-
-export const getBgColor = (elem?: Element | null | undefined): string | null => {
-	if (elem == null) return null;
-
-	const { backgroundColor: bg } = window.getComputedStyle(elem);
-
-	if (bg && tinycolor(bg).getAlpha() !== 0) {
-		return bg;
-	}
-
-	return getBgColor(elem.parentElement);
-};
diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/scripts/get-dom-node-or-null.ts
deleted file mode 100644
index fbf54675fd..0000000000
--- a/packages/frontend/src/scripts/get-dom-node-or-null.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const getNodeOrNull = (input: unknown): Node | null => {
-	if (input instanceof Node) return input;
-	return null;
-};
-
-export const getElementOrNull = (input: unknown): Element | null => {
-	if (input instanceof Element) return input;
-	return null;
-};
-
-export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => {
-	if (input instanceof HTMLElement) return input;
-	return null;
-};
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
deleted file mode 100644
index 6ac0684170..0000000000
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { defineAsyncComponent } from 'vue';
-import type { MenuItem } from '@/types/menu.js';
-import { i18n } from '@/i18n.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { prefer } from '@/preferences.js';
-
-function rename(file: Misskey.entities.DriveFile) {
-	os.inputText({
-		title: i18n.ts.renameFile,
-		placeholder: i18n.ts.inputNewFileName,
-		default: file.name,
-	}).then(({ canceled, result: name }) => {
-		if (canceled) return;
-		misskeyApi('drive/files/update', {
-			fileId: file.id,
-			name: name,
-		});
-	});
-}
-
-function describe(file: Misskey.entities.DriveFile) {
-	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
-		default: file.comment ?? '',
-		file: file,
-	}, {
-		done: caption => {
-			misskeyApi('drive/files/update', {
-				fileId: file.id,
-				comment: caption.length === 0 ? null : caption,
-			});
-		},
-		closed: () => dispose(),
-	});
-}
-
-function move(file: Misskey.entities.DriveFile) {
-	os.selectDriveFolder(false).then(folder => {
-		misskeyApi('drive/files/update', {
-			fileId: file.id,
-			folderId: folder[0] ? folder[0].id : null,
-		});
-	});
-}
-
-function toggleSensitive(file: Misskey.entities.DriveFile) {
-	misskeyApi('drive/files/update', {
-		fileId: file.id,
-		isSensitive: !file.isSensitive,
-	}).catch(err => {
-		os.alert({
-			type: 'error',
-			title: i18n.ts.error,
-			text: err.message,
-		});
-	});
-}
-
-function copyUrl(file: Misskey.entities.DriveFile) {
-	copyToClipboard(file.url);
-	os.success();
-}
-
-/*
-function addApp() {
-	alert('not implemented yet');
-}
-*/
-async function deleteFile(file: Misskey.entities.DriveFile) {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
-	});
-
-	if (canceled) return;
-	misskeyApi('drive/files/delete', {
-		fileId: file.id,
-	});
-}
-
-export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
-	const isImage = file.type.startsWith('image/');
-
-	const menuItems: MenuItem[] = [];
-
-	menuItems.push({
-		type: 'link',
-		to: `/my/drive/file/${file.id}`,
-		text: i18n.ts._fileViewer.title,
-		icon: 'ti ti-info-circle',
-	}, { type: 'divider' }, {
-		text: i18n.ts.rename,
-		icon: 'ti ti-forms',
-		action: () => rename(file),
-	}, {
-		text: i18n.ts.move,
-		icon: 'ti ti-folder-symlink',
-		action: () => move(file),
-	}, {
-		text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
-		icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
-		action: () => toggleSensitive(file),
-	}, {
-		text: i18n.ts.describeFile,
-		icon: 'ti ti-text-caption',
-		action: () => describe(file),
-	});
-
-	if (isImage) {
-		menuItems.push({
-			text: i18n.ts.cropImage,
-			icon: 'ti ti-crop',
-			action: () => os.cropImage(file, {
-				aspectRatio: NaN,
-				uploadFolder: folder ? folder.id : folder,
-			}),
-		});
-	}
-
-	menuItems.push({ type: 'divider' }, {
-		text: i18n.ts.createNoteFromTheFile,
-		icon: 'ti ti-pencil',
-		action: () => os.post({
-			initialFiles: [file],
-		}),
-	}, {
-		text: i18n.ts.copyUrl,
-		icon: 'ti ti-link',
-		action: () => copyUrl(file),
-	}, {
-		type: 'a',
-		href: file.url,
-		target: '_blank',
-		text: i18n.ts.download,
-		icon: 'ti ti-download',
-		download: file.name,
-	}, { type: 'divider' }, {
-		text: i18n.ts.delete,
-		icon: 'ti ti-trash',
-		danger: true,
-		action: () => deleteFile(file),
-	});
-
-	if (prefer.s.devMode) {
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-id',
-			text: i18n.ts.copyFileId,
-			action: () => {
-				copyToClipboard(file.id);
-			},
-		});
-	}
-
-	return menuItems;
-}
diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/scripts/get-embed-code.ts
deleted file mode 100644
index 158ab9c7f8..0000000000
--- a/packages/frontend/src/scripts/get-embed-code.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { defineAsyncComponent } from 'vue';
-import { v4 as uuid } from 'uuid';
-import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
-import { url } from '@@/js/config.js';
-import * as os from '@/os.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
-
-const MOBILE_THRESHOLD = 500;
-
-/**
- * パラメータを正規化する（埋め込みコード作成用）
- * @param params パラメータ
- * @returns 正規化されたパラメータ
- */
-export function normalizeEmbedParams(params: EmbedParams): Record<string, string> {
-	// paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す
-	const normalizedParams: Record<string, string> = {};
-	for (const key in params) {
-		// デフォルトの値と同じならparamsに含めない
-		if (params[key] == null || params[key] === defaultEmbedParams[key]) {
-			continue;
-		}
-		switch (typeof params[key]) {
-			case 'number':
-				normalizedParams[key] = params[key].toString();
-				break;
-			case 'boolean':
-				normalizedParams[key] = params[key] ? 'true' : 'false';
-				break;
-			default:
-				normalizedParams[key] = params[key];
-				break;
-		}
-	}
-	return normalizedParams;
-}
-
-/**
- * 埋め込みコードを生成（iframe IDの発番もやる）
- */
-export function getEmbedCode(path: string, params?: EmbedParams): string {
-	const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
-
-	let paramString = '';
-	if (params) {
-		const searchParams = new URLSearchParams(normalizeEmbedParams(params));
-		paramString = searchParams.toString() === '' ? '' : '?' + searchParams.toString();
-	}
-
-	const iframeCode = [
-		`<iframe src="${url + path + paramString}" data-misskey-embed-id="${iframeId}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="border: none; width: 100%; max-width: 500px; height: 300px; color-scheme: light dark;"></iframe>`,
-		`<script defer src="${url}/embed.js"></script>`,
-	];
-	return iframeCode.join('\n');
-}
-
-/**
- * 埋め込みコードを生成してコピーする（カスタマイズ機能つき）
- *
- * カスタマイズ機能がいらない場合（事前にパラメータを指定する場合）は getEmbedCode を直接使ってください
- */
-export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
-	const _params = { ...params };
-
-	if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
-		_params.maxHeight = 700;
-	}
-
-	// PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
-	if (window.innerWidth < MOBILE_THRESHOLD) {
-		copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
-		os.success();
-	} else {
-		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
-			entity,
-			id,
-			params: _params,
-		}, {
-			closed: () => dispose(),
-		});
-	}
-}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
deleted file mode 100644
index 8ce4a81bd4..0000000000
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ /dev/null
@@ -1,685 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
-import * as Misskey from 'misskey-js';
-import { url } from '@@/js/config.js';
-import { claimAchievement } from './achievements.js';
-import type { MenuItem } from '@/types/menu.js';
-import { $i } from '@/account.js';
-import { i18n } from '@/i18n.js';
-import { instance } from '@/instance.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { store, noteActions } from '@/store.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { getUserMenu } from '@/scripts/get-user-menu.js';
-import { clipsCache, favoritedChannelsCache } from '@/cache.js';
-import MkRippleEffect from '@/components/MkRippleEffect.vue';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { getAppearNote } from '@/scripts/get-appear-note.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import { prefer } from '@/preferences.js';
-
-export async function getNoteClipMenu(props: {
-	note: Misskey.entities.Note;
-	isDeleted: Ref<boolean>;
-	currentClip?: Misskey.entities.Clip;
-}) {
-	function getClipName(clip: Misskey.entities.Clip) {
-		if ($i && clip.userId === $i.id && clip.notesCount != null) {
-			return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
-		} else {
-			return clip.name;
-		}
-	}
-
-	const appearNote = getAppearNote(props.note);
-
-	const clips = await clipsCache.fetch();
-	const menu: MenuItem[] = [...clips.map(clip => ({
-		text: getClipName(clip),
-		action: () => {
-			claimAchievement('noteClipped1');
-			os.promiseDialog(
-				misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
-				null,
-				async (err) => {
-					if (err.id === '734806c4-542c-463a-9311-15c512803965') {
-						const confirm = await os.confirm({
-							type: 'warning',
-							text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
-						});
-						if (!confirm.canceled) {
-							os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
-								clipsCache.set(clips.map(c => {
-									if (c.id === clip.id) {
-										return {
-											...c,
-											notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
-										};
-									} else {
-										return c;
-									}
-								}));
-							});
-							if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
-						}
-					} else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
-						os.alert({
-							type: 'error',
-							text: i18n.ts.clipNoteLimitExceeded,
-						});
-					} else {
-						os.alert({
-							type: 'error',
-							text: err.message + '\n' + err.id,
-						});
-					}
-				},
-			).then(() => {
-				clipsCache.set(clips.map(c => {
-					if (c.id === clip.id) {
-						return {
-							...c,
-							notesCount: (c.notesCount ?? 0) + 1,
-						};
-					} else {
-						return c;
-					}
-				}));
-			});
-		},
-	})), { type: 'divider' }, {
-		icon: 'ti ti-plus',
-		text: i18n.ts.createNew,
-		action: async () => {
-			const { canceled, result } = await os.form(i18n.ts.createNewClip, {
-				name: {
-					type: 'string',
-					default: null,
-					label: i18n.ts.name,
-				},
-				description: {
-					type: 'string',
-					required: false,
-					default: null,
-					multiline: true,
-					label: i18n.ts.description,
-				},
-				isPublic: {
-					type: 'boolean',
-					label: i18n.ts.public,
-					default: false,
-				},
-			});
-			if (canceled) return;
-
-			const clip = await os.apiWithDialog('clips/create', result);
-
-			clipsCache.delete();
-
-			claimAchievement('noteClipped1');
-			os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
-		},
-	}];
-
-	return menu;
-}
-
-export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
-	return {
-		icon: 'ti ti-exclamation-circle',
-		text,
-		action: (): void => {
-			const localUrl = `${url}/notes/${note.id}`;
-			let noteInfo = '';
-			if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
-			noteInfo += `Local Note: ${localUrl}\n`;
-			const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
-				user: note.user,
-				initialComment: `${noteInfo}-----\n`,
-			}, {
-				closed: () => dispose(),
-			});
-		},
-	};
-}
-
-export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
-	return {
-		icon: 'ti ti-link',
-		text,
-		action: (): void => {
-			copyToClipboard(`${url}/notes/${note.id}`);
-			os.success();
-		},
-	};
-}
-
-function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
-	if (note.url != null || note.uri != null) return undefined;
-	if (['specified', 'followers'].includes(note.visibility)) return undefined;
-
-	return {
-		icon: 'ti ti-code',
-		text,
-		action: (): void => {
-			genEmbedCode('notes', note.id);
-		},
-	};
-}
-
-export function getNoteMenu(props: {
-	note: Misskey.entities.Note;
-	translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
-	translating: Ref<boolean>;
-	isDeleted: Ref<boolean>;
-	currentClip?: Misskey.entities.Clip;
-}) {
-	const appearNote = getAppearNote(props.note);
-
-	const cleanups = [] as (() => void)[];
-
-	function del(): void {
-		os.confirm({
-			type: 'warning',
-			text: i18n.ts.noteDeleteConfirm,
-		}).then(({ canceled }) => {
-			if (canceled) return;
-
-			misskeyApi('notes/delete', {
-				noteId: appearNote.id,
-			});
-
-			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
-				claimAchievement('noteDeletedWithin1min');
-			}
-		});
-	}
-
-	function delEdit(): void {
-		os.confirm({
-			type: 'warning',
-			text: i18n.ts.deleteAndEditConfirm,
-		}).then(({ canceled }) => {
-			if (canceled) return;
-
-			misskeyApi('notes/delete', {
-				noteId: appearNote.id,
-			});
-
-			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
-
-			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
-				claimAchievement('noteDeletedWithin1min');
-			}
-		});
-	}
-
-	function toggleFavorite(favorite: boolean): void {
-		claimAchievement('noteFavorited1');
-		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
-			noteId: appearNote.id,
-		});
-	}
-
-	function toggleThreadMute(mute: boolean): void {
-		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
-			noteId: appearNote.id,
-		});
-	}
-
-	function copyContent(): void {
-		copyToClipboard(appearNote.text);
-		os.success();
-	}
-
-	function togglePin(pin: boolean): void {
-		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
-			noteId: appearNote.id,
-		}, undefined, {
-			'72dab508-c64d-498f-8740-a8eec1ba385a': {
-				text: i18n.ts.pinLimitExceeded,
-			},
-		});
-	}
-
-	async function unclip(): Promise<void> {
-		if (!props.currentClip) return;
-		os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
-		props.isDeleted.value = true;
-	}
-
-	async function promote(): Promise<void> {
-		const { canceled, result: days } = await os.inputNumber({
-			title: i18n.ts.numberOfDays,
-		});
-
-		if (canceled || days == null) return;
-
-		os.apiWithDialog('admin/promo/create', {
-			noteId: appearNote.id,
-			expiresAt: Date.now() + (86400000 * days),
-		});
-	}
-
-	function share(): void {
-		navigator.share({
-			title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }),
-			text: appearNote.text ?? '',
-			url: `${url}/notes/${appearNote.id}`,
-		});
-	}
-
-	function openDetail(): void {
-		os.pageWindow(`/notes/${appearNote.id}`);
-	}
-
-	async function translate(): Promise<void> {
-		if (props.translation.value != null) return;
-		props.translating.value = true;
-		const res = await misskeyApi('notes/translate', {
-			noteId: appearNote.id,
-			targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
-		});
-		props.translating.value = false;
-		props.translation.value = res;
-	}
-
-	const menuItems: MenuItem[] = [];
-
-	if ($i) {
-		const statePromise = misskeyApi('notes/state', {
-			noteId: appearNote.id,
-		});
-
-		if (props.currentClip?.userId === $i.id) {
-			menuItems.push({
-				icon: 'ti ti-backspace',
-				text: i18n.ts.unclip,
-				danger: true,
-				action: unclip,
-			}, { type: 'divider' });
-		}
-
-		menuItems.push({
-			icon: 'ti ti-info-circle',
-			text: i18n.ts.details,
-			action: openDetail,
-		}, {
-			icon: 'ti ti-copy',
-			text: i18n.ts.copyContent,
-			action: copyContent,
-		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
-
-		if (appearNote.url || appearNote.uri) {
-			menuItems.push({
-				icon: 'ti ti-link',
-				text: i18n.ts.copyRemoteLink,
-				action: () => {
-					copyToClipboard(appearNote.url ?? appearNote.uri);
-					os.success();
-				},
-			}, {
-				icon: 'ti ti-external-link',
-				text: i18n.ts.showOnRemote,
-				action: () => {
-					window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
-				},
-			});
-		} else {
-			menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
-		}
-
-		if (isSupportShare()) {
-			menuItems.push({
-				icon: 'ti ti-share',
-				text: i18n.ts.share,
-				action: share,
-			});
-		}
-
-		if ($i.policies.canUseTranslator && instance.translatorAvailable) {
-			menuItems.push({
-				icon: 'ti ti-language-hiragana',
-				text: i18n.ts.translate,
-				action: translate,
-			});
-		}
-
-		menuItems.push({ type: 'divider' });
-
-		menuItems.push(statePromise.then(state => state.isFavorited ? {
-			icon: 'ti ti-star-off',
-			text: i18n.ts.unfavorite,
-			action: () => toggleFavorite(false),
-		} : {
-			icon: 'ti ti-star',
-			text: i18n.ts.favorite,
-			action: () => toggleFavorite(true),
-		}));
-
-		menuItems.push({
-			type: 'parent',
-			icon: 'ti ti-paperclip',
-			text: i18n.ts.clip,
-			children: () => getNoteClipMenu(props),
-		});
-
-		menuItems.push(statePromise.then(state => state.isMutedThread ? {
-			icon: 'ti ti-message-off',
-			text: i18n.ts.unmuteThread,
-			action: () => toggleThreadMute(false),
-		} : {
-			icon: 'ti ti-message-off',
-			text: i18n.ts.muteThread,
-			action: () => toggleThreadMute(true),
-		}));
-
-		if (appearNote.userId === $i.id) {
-			if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
-				menuItems.push({
-					icon: 'ti ti-pinned-off',
-					text: i18n.ts.unpin,
-					action: () => togglePin(false),
-				});
-			} else {
-				menuItems.push({
-					icon: 'ti ti-pin',
-					text: i18n.ts.pin,
-					action: () => togglePin(true),
-				});
-			}
-		}
-
-		menuItems.push({
-			type: 'parent',
-			icon: 'ti ti-user',
-			text: i18n.ts.user,
-			children: async () => {
-				const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
-				const { menu, cleanup } = getUserMenu(user);
-				cleanups.push(cleanup);
-				return menu;
-			},
-		});
-
-		if (appearNote.userId !== $i.id) {
-			menuItems.push({ type: 'divider' });
-			menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
-		}
-
-		if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {
-			menuItems.push({ type: 'divider' });
-			menuItems.push({
-				type: 'parent',
-				icon: 'ti ti-device-tv',
-				text: i18n.ts.channel,
-				children: async () => {
-					const channelChildMenu = [] as MenuItem[];
-
-					const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
-
-					if (channel.pinnedNoteIds.includes(appearNote.id)) {
-						channelChildMenu.push({
-							icon: 'ti ti-pinned-off',
-							text: i18n.ts.unpin,
-							action: () => os.apiWithDialog('channels/update', {
-								channelId: appearNote.channel!.id,
-								pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
-							}),
-						});
-					} else {
-						channelChildMenu.push({
-							icon: 'ti ti-pin',
-							text: i18n.ts.pin,
-							action: () => os.apiWithDialog('channels/update', {
-								channelId: appearNote.channel!.id,
-								pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
-							}),
-						});
-					}
-					return channelChildMenu;
-				},
-			});
-		}
-
-		if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) {
-			menuItems.push({ type: 'divider' });
-			if (appearNote.userId === $i.id) {
-				menuItems.push({
-					icon: 'ti ti-edit',
-					text: i18n.ts.deleteAndEdit,
-					action: delEdit,
-				});
-			}
-			menuItems.push({
-				icon: 'ti ti-trash',
-				text: i18n.ts.delete,
-				danger: true,
-				action: del,
-			});
-		}
-	} else {
-		menuItems.push({
-			icon: 'ti ti-info-circle',
-			text: i18n.ts.details,
-			action: openDetail,
-		}, {
-			icon: 'ti ti-copy',
-			text: i18n.ts.copyContent,
-			action: copyContent,
-		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
-
-		if (appearNote.url || appearNote.uri) {
-			menuItems.push({
-				icon: 'ti ti-link',
-				text: i18n.ts.copyRemoteLink,
-				action: () => {
-					copyToClipboard(appearNote.url ?? appearNote.uri);
-					os.success();
-				},
-			}, {
-				icon: 'ti ti-external-link',
-				text: i18n.ts.showOnRemote,
-				action: () => {
-					window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
-				},
-			});
-		} else {
-			menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
-		}
-	}
-
-	if (noteActions.length > 0) {
-		menuItems.push({ type: 'divider' });
-
-		menuItems.push(...noteActions.map(action => ({
-			icon: 'ti ti-plug',
-			text: action.title,
-			action: () => {
-				action.handler(appearNote);
-			},
-		})));
-	}
-
-	if (prefer.s.devMode) {
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-id',
-			text: i18n.ts.copyNoteId,
-			action: () => {
-				copyToClipboard(appearNote.id);
-				os.success();
-			},
-		});
-	}
-
-	const cleanup = () => {
-		if (_DEV_) console.log('note menu cleanup', cleanups);
-		for (const cl of cleanups) {
-			cl();
-		}
-	};
-
-	return {
-		menu: menuItems,
-		cleanup,
-	};
-}
-
-type Visibility = (typeof Misskey.noteVisibilities)[number];
-
-function smallerVisibility(a: Visibility, b: Visibility): Visibility {
-	if (a === 'specified' || b === 'specified') return 'specified';
-	if (a === 'followers' || b === 'followers') return 'followers';
-	if (a === 'home' || b === 'home') return 'home';
-	// if (a === 'public' || b === 'public')
-	return 'public';
-}
-
-export function getRenoteMenu(props: {
-	note: Misskey.entities.Note;
-	renoteButton: ShallowRef<HTMLElement | undefined>;
-	mock?: boolean;
-}) {
-	const appearNote = getAppearNote(props.note);
-
-	const channelRenoteItems: MenuItem[] = [];
-	const normalRenoteItems: MenuItem[] = [];
-	const normalExternalChannelRenoteItems: MenuItem[] = [];
-
-	if (appearNote.channel) {
-		channelRenoteItems.push(...[{
-			text: i18n.ts.inChannelRenote,
-			icon: 'ti ti-repeat',
-			action: () => {
-				const el = props.renoteButton.value;
-				if (el && prefer.s.animation) {
-					const rect = el.getBoundingClientRect();
-					const x = rect.left + (el.offsetWidth / 2);
-					const y = rect.top + (el.offsetHeight / 2);
-					const { dispose } = os.popup(MkRippleEffect, { x, y }, {
-						end: () => dispose(),
-					});
-				}
-
-				if (!props.mock) {
-					misskeyApi('notes/create', {
-						renoteId: appearNote.id,
-						channelId: appearNote.channelId,
-					}).then(() => {
-						os.toast(i18n.ts.renoted);
-					});
-				}
-			},
-		}, {
-			text: i18n.ts.inChannelQuote,
-			icon: 'ti ti-quote',
-			action: () => {
-				if (!props.mock) {
-					os.post({
-						renote: appearNote,
-						channel: appearNote.channel,
-					});
-				}
-			},
-		}]);
-	}
-
-	if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
-		normalRenoteItems.push(...[{
-			text: i18n.ts.renote,
-			icon: 'ti ti-repeat',
-			action: () => {
-				const el = props.renoteButton.value;
-				if (el && prefer.s.animation) {
-					const rect = el.getBoundingClientRect();
-					const x = rect.left + (el.offsetWidth / 2);
-					const y = rect.top + (el.offsetHeight / 2);
-					const { dispose } = os.popup(MkRippleEffect, { x, y }, {
-						end: () => dispose(),
-					});
-				}
-
-				const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility;
-				const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly;
-
-				let visibility = appearNote.visibility;
-				visibility = smallerVisibility(visibility, configuredVisibility);
-				if (appearNote.channel?.isSensitive) {
-					visibility = smallerVisibility(visibility, 'home');
-				}
-
-				if (!props.mock) {
-					misskeyApi('notes/create', {
-						localOnly,
-						visibility,
-						renoteId: appearNote.id,
-					}).then(() => {
-						os.toast(i18n.ts.renoted);
-					});
-				}
-			},
-		}, (props.mock) ? undefined : {
-			text: i18n.ts.quote,
-			icon: 'ti ti-quote',
-			action: () => {
-				os.post({
-					renote: appearNote,
-				});
-			},
-		}]);
-
-		normalExternalChannelRenoteItems.push({
-			type: 'parent',
-			icon: 'ti ti-repeat',
-			text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
-			children: async () => {
-				const channels = await favoritedChannelsCache.fetch();
-				return channels.filter((channel) => {
-					if (!appearNote.channelId) return true;
-					return channel.id !== appearNote.channelId;
-				}).map((channel) => ({
-					text: channel.name,
-					action: () => {
-						const el = props.renoteButton.value;
-						if (el && prefer.s.animation) {
-							const rect = el.getBoundingClientRect();
-							const x = rect.left + (el.offsetWidth / 2);
-							const y = rect.top + (el.offsetHeight / 2);
-							const { dispose } = os.popup(MkRippleEffect, { x, y }, {
-								end: () => dispose(),
-							});
-						}
-
-						if (!props.mock) {
-							misskeyApi('notes/create', {
-								renoteId: appearNote.id,
-								channelId: channel.id,
-							}).then(() => {
-								os.toast(i18n.tsx.renotedToX({ name: channel.name }));
-							});
-						}
-					},
-				}));
-			},
-		});
-	}
-
-	const renoteItems = [
-		...normalRenoteItems,
-		...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
-		...channelRenoteItems,
-		...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
-		...normalExternalChannelRenoteItems,
-	];
-
-	return {
-		menu: renoteItems,
-	};
-}
diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts
deleted file mode 100644
index 6fd9947ac1..0000000000
--- a/packages/frontend/src/scripts/get-note-summary.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { i18n } from '@/i18n.js';
-
-/**
- * 投稿を表す文字列を取得します。
- * @param {*} note (packされた)投稿
- */
-export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
-	if (note == null) {
-		return '';
-	}
-
-	if (note.deletedAt) {
-		return `(${i18n.ts.deletedNote})`;
-	}
-
-	if (note.isHidden) {
-		return `(${i18n.ts.invisibleNote})`;
-	}
-
-	let summary = '';
-
-	// 本文
-	if (note.cw != null) {
-		summary += note.cw;
-	} else {
-		summary += note.text ? note.text : '';
-	}
-
-	// ファイルが添付されているとき
-	if ((note.files || []).length !== 0) {
-		summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
-	}
-
-	// 投票が添付されているとき
-	if (note.poll) {
-		summary += ` (${i18n.ts.poll})`;
-	}
-
-	// 返信のとき
-	if (note.replyId) {
-		if (note.reply) {
-			summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
-		} else {
-			summary += '\n\nRE: ...';
-		}
-	}
-
-	// Renoteのとき
-	if (note.renoteId) {
-		if (note.renote) {
-			summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
-		} else {
-			summary += '\n\nRN: ...';
-		}
-	}
-
-	return summary.trim();
-};
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
deleted file mode 100644
index 6892c3a4e4..0000000000
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ /dev/null
@@ -1,441 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { toUnicode } from 'punycode.js';
-import { defineAsyncComponent, ref, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import { host, url } from '@@/js/config.js';
-import type { IRouter } from '@/nirax.js';
-import type { MenuItem } from '@/types/menu.js';
-import { i18n } from '@/i18n.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { userActions } from '@/store.js';
-import { $i, iAmModerator } from '@/account.js';
-import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
-import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
-import { mainRouter } from '@/router/main.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import { prefer } from '@/preferences.js';
-
-export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
-	const meId = $i ? $i.id : null;
-
-	const cleanups = [] as (() => void)[];
-
-	async function toggleMute() {
-		if (user.isMuted) {
-			os.apiWithDialog('mute/delete', {
-				userId: user.id,
-			}).then(() => {
-				user.isMuted = false;
-			});
-		} else {
-			const { canceled, result: period } = await os.select({
-				title: i18n.ts.mutePeriod,
-				items: [{
-					value: 'indefinitely', text: i18n.ts.indefinitely,
-				}, {
-					value: 'tenMinutes', text: i18n.ts.tenMinutes,
-				}, {
-					value: 'oneHour', text: i18n.ts.oneHour,
-				}, {
-					value: 'oneDay', text: i18n.ts.oneDay,
-				}, {
-					value: 'oneWeek', text: i18n.ts.oneWeek,
-				}],
-				default: 'indefinitely',
-			});
-			if (canceled) return;
-
-			const expiresAt = period === 'indefinitely' ? null
-				: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
-				: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
-				: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
-				: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
-				: null;
-
-			os.apiWithDialog('mute/create', {
-				userId: user.id,
-				expiresAt,
-			}).then(() => {
-				user.isMuted = true;
-			});
-		}
-	}
-
-	async function toggleRenoteMute() {
-		os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
-			userId: user.id,
-		}).then(() => {
-			user.isRenoteMuted = !user.isRenoteMuted;
-		});
-	}
-
-	async function toggleBlock() {
-		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
-
-		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
-			userId: user.id,
-		}).then(() => {
-			user.isBlocking = !user.isBlocking;
-		});
-	}
-
-	async function toggleNotify() {
-		os.apiWithDialog('following/update', {
-			userId: user.id,
-			notify: user.notify === 'normal' ? 'none' : 'normal',
-		}).then(() => {
-			user.notify = user.notify === 'normal' ? 'none' : 'normal';
-		});
-	}
-
-	function reportAbuse() {
-		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
-			user: user,
-		}, {
-			closed: () => dispose(),
-		});
-	}
-
-	async function getConfirmed(text: string): Promise<boolean> {
-		const confirm = await os.confirm({
-			type: 'warning',
-			title: 'confirm',
-			text,
-		});
-
-		return !confirm.canceled;
-	}
-
-	async function userInfoUpdate() {
-		os.apiWithDialog('federation/update-remote-user', {
-			userId: user.id,
-		});
-	}
-
-	async function invalidateFollow() {
-		if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
-
-		os.apiWithDialog('following/invalidate', {
-			userId: user.id,
-		}).then(() => {
-			user.isFollowed = !user.isFollowed;
-		});
-	}
-
-	async function editMemo(): Promise<void> {
-		const userDetailed = await misskeyApi('users/show', {
-			userId: user.id,
-		});
-		const { canceled, result } = await os.form(i18n.ts.editMemo, {
-			memo: {
-				type: 'string',
-				required: true,
-				multiline: true,
-				label: i18n.ts.memo,
-				default: userDetailed.memo,
-			},
-		});
-		if (canceled) return;
-
-		os.apiWithDialog('users/update-memo', {
-			memo: result.memo,
-			userId: user.id,
-		});
-	}
-
-	const menuItems: MenuItem[] = [];
-
-	menuItems.push({
-		icon: 'ti ti-at',
-		text: i18n.ts.copyUsername,
-		action: () => {
-			copyToClipboard(`@${user.username}@${user.host ?? host}`);
-		},
-	});
-
-	if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
-		menuItems.push({
-			icon: 'ti ti-search',
-			text: i18n.ts.searchThisUsersNotes,
-			action: () => {
-				router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
-			},
-		});
-	}
-
-	if (iAmModerator) {
-		menuItems.push({
-			icon: 'ti ti-user-exclamation',
-			text: i18n.ts.moderation,
-			action: () => {
-				router.push(`/admin/user/${user.id}`);
-			},
-		});
-	}
-
-	menuItems.push({
-		icon: 'ti ti-rss',
-		text: i18n.ts.copyRSS,
-		action: () => {
-			copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
-		},
-	});
-
-	if (user.host != null && user.url != null) {
-		menuItems.push({
-			icon: 'ti ti-external-link',
-			text: i18n.ts.showOnRemote,
-			action: () => {
-				if (user.url == null) return;
-				window.open(user.url, '_blank', 'noopener');
-			},
-		});
-	} else {
-		menuItems.push({
-			icon: 'ti ti-code',
-			text: i18n.ts.genEmbedCode,
-			type: 'parent',
-			children: [{
-				text: i18n.ts.noteOfThisUser,
-				action: () => {
-					genEmbedCode('user-timeline', user.id);
-				},
-			}], // TODO: ユーザーカードの埋め込みなど
-		});
-	}
-
-	menuItems.push({
-		icon: 'ti ti-share',
-		text: i18n.ts.copyProfileUrl,
-		action: () => {
-			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
-			copyToClipboard(`${url}/${canonical}`);
-		},
-	});
-
-	if ($i) {
-		menuItems.push({
-			icon: 'ti ti-mail',
-			text: i18n.ts.sendMessage,
-			action: () => {
-				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
-				os.post({ specified: user, initialText: `${canonical} ` });
-			},
-		}, { type: 'divider' }, {
-			icon: 'ti ti-pencil',
-			text: i18n.ts.editMemo,
-			action: editMemo,
-		}, {
-			type: 'parent',
-			icon: 'ti ti-list',
-			text: i18n.ts.addToList,
-			children: async () => {
-				const lists = await userListsCache.fetch();
-				return lists.map(list => {
-					const isListed = ref(list.userIds?.includes(user.id) ?? false);
-					cleanups.push(watch(isListed, () => {
-						if (isListed.value) {
-							os.apiWithDialog('users/lists/push', {
-								listId: list.id,
-								userId: user.id,
-							}).then(() => {
-								list.userIds?.push(user.id);
-							});
-						} else {
-							os.apiWithDialog('users/lists/pull', {
-								listId: list.id,
-								userId: user.id,
-							}).then(() => {
-								list.userIds?.splice(list.userIds.indexOf(user.id), 1);
-							});
-						}
-					}));
-
-					return {
-						type: 'switch',
-						text: list.name,
-						ref: isListed,
-					};
-				});
-			},
-		}, {
-			type: 'parent',
-			icon: 'ti ti-antenna',
-			text: i18n.ts.addToAntenna,
-			children: async () => {
-				const antennas = await antennasCache.fetch();
-				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
-				return antennas.filter((a) => a.src === 'users').map(antenna => ({
-					text: antenna.name,
-					action: async () => {
-						await os.apiWithDialog('antennas/update', {
-							antennaId: antenna.id,
-							name: antenna.name,
-							keywords: antenna.keywords,
-							excludeKeywords: antenna.excludeKeywords,
-							src: antenna.src,
-							userListId: antenna.userListId,
-							users: [...antenna.users, canonical],
-							caseSensitive: antenna.caseSensitive,
-							withReplies: antenna.withReplies,
-							withFile: antenna.withFile,
-							notify: antenna.notify,
-						});
-						antennasCache.delete();
-					},
-				}));
-			},
-		});
-	}
-
-	if ($i && meId !== user.id) {
-		if (iAmModerator) {
-			menuItems.push({
-				type: 'parent',
-				icon: 'ti ti-badges',
-				text: i18n.ts.roles,
-				children: async () => {
-					const roles = await rolesCache.fetch();
-
-					return roles.filter(r => r.target === 'manual').map(r => ({
-						text: r.name,
-						action: async () => {
-							const { canceled, result: period } = await os.select({
-								title: i18n.ts.period + ': ' + r.name,
-								items: [{
-									value: 'indefinitely', text: i18n.ts.indefinitely,
-								}, {
-									value: 'oneHour', text: i18n.ts.oneHour,
-								}, {
-									value: 'oneDay', text: i18n.ts.oneDay,
-								}, {
-									value: 'oneWeek', text: i18n.ts.oneWeek,
-								}, {
-									value: 'oneMonth', text: i18n.ts.oneMonth,
-								}],
-								default: 'indefinitely',
-							});
-							if (canceled) return;
-
-							const expiresAt = period === 'indefinitely' ? null
-								: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
-								: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
-								: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
-								: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
-								: null;
-
-							os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
-						},
-					}));
-				},
-			});
-		}
-
-		// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
-		//if (user.isFollowing) {
-		const withRepliesRef = ref(user.withReplies ?? false);
-
-		menuItems.push({
-			type: 'switch',
-			icon: 'ti ti-messages',
-			text: i18n.ts.showRepliesToOthersInTimeline,
-			ref: withRepliesRef,
-		}, {
-			icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
-			text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
-			action: toggleNotify,
-		});
-
-		watch(withRepliesRef, (withReplies) => {
-			misskeyApi('following/update', {
-				userId: user.id,
-				withReplies,
-			}).then(() => {
-				user.withReplies = withReplies;
-			});
-		});
-		//}
-
-		menuItems.push({ type: 'divider' }, {
-			icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
-			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
-			action: toggleMute,
-		}, {
-			icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off',
-			text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
-			action: toggleRenoteMute,
-		}, {
-			icon: 'ti ti-ban',
-			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
-			action: toggleBlock,
-		});
-
-		if (user.isFollowed) {
-			menuItems.push({
-				icon: 'ti ti-link-off',
-				text: i18n.ts.breakFollow,
-				action: invalidateFollow,
-			});
-		}
-
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-exclamation-circle',
-			text: i18n.ts.reportAbuse,
-			action: reportAbuse,
-		});
-	}
-
-	if (user.host !== null) {
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-refresh',
-			text: i18n.ts.updateRemoteUser,
-			action: userInfoUpdate,
-		});
-	}
-
-	if (prefer.s.devMode) {
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-id',
-			text: i18n.ts.copyUserId,
-			action: () => {
-				copyToClipboard(user.id);
-			},
-		});
-	}
-
-	if ($i && meId === user.id) {
-		menuItems.push({ type: 'divider' }, {
-			icon: 'ti ti-pencil',
-			text: i18n.ts.editProfile,
-			action: () => {
-				router.push('/settings/profile');
-			},
-		});
-	}
-
-	if (userActions.length > 0) {
-		menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
-			icon: 'ti ti-plug',
-			text: action.title,
-			action: () => {
-				action.handler(user);
-			},
-		})));
-	}
-
-	return {
-		menu: menuItems,
-		cleanup: () => {
-			if (_DEV_) console.log('user menu cleanup', cleanups);
-			for (const cl of cleanups) {
-				cl();
-			}
-		},
-	};
-}
diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts
deleted file mode 100644
index 56e91abba0..0000000000
--- a/packages/frontend/src/scripts/get-user-name.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export default function(user: { name?: string | null, username: string }): string {
-	return user.name === '' ? user.username : user.name ?? user.username;
-}
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
deleted file mode 100644
index 04fb235694..0000000000
--- a/packages/frontend/src/scripts/hotkey.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js";
-
-//#region types
-export type Keymap = Record<string, CallbackFunction | CallbackObject>;
-
-type CallbackFunction = (ev: KeyboardEvent) => unknown;
-
-type CallbackObject = {
-	callback: CallbackFunction;
-	allowRepeat?: boolean;
-};
-
-type Pattern = {
-	which: string[];
-	ctrl: boolean;
-	alt: boolean;
-	shift: boolean;
-};
-
-type Action = {
-	patterns: Pattern[];
-	callback: CallbackFunction;
-	options: Required<Omit<CallbackObject, 'callback'>>;
-};
-//#endregion
-
-//#region consts
-const KEY_ALIASES = {
-	'esc': 'Escape',
-	'enter': 'Enter',
-	'space': ' ',
-	'up': 'ArrowUp',
-	'down': 'ArrowDown',
-	'left': 'ArrowLeft',
-	'right': 'ArrowRight',
-	'plus': ['+', ';'],
-};
-
-const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
-
-const IGNORE_ELEMENTS = ['input', 'textarea'];
-//#endregion
-
-//#region store
-let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
-//#endregion
-
-//#region impl
-export const makeHotkey = (keymap: Keymap) => {
-	const actions = parseKeymap(keymap);
-	return (ev: KeyboardEvent) => {
-		if ('pswp' in window && window.pswp != null) return;
-		if (document.activeElement != null) {
-			if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
-			if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
-		}
-		for (const action of actions) {
-			if (matchPatterns(ev, action)) {
-				ev.preventDefault();
-				ev.stopPropagation();
-				action.callback(ev);
-				storePattern(ev, action.callback);
-			}
-		}
-	};
-};
-
-const parseKeymap = (keymap: Keymap) => {
-	return Object.entries(keymap).map(([rawPatterns, rawCallback]) => {
-		const patterns = parsePatterns(rawPatterns);
-		const callback = parseCallback(rawCallback);
-		const options = parseOptions(rawCallback);
-		return { patterns, callback, options } as const satisfies Action;
-	});
-};
-
-const parsePatterns = (rawPatterns: keyof Keymap) => {
-	return rawPatterns.split('|').map(part => {
-		const keys = part.split('+').map(trimLower);
-		const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x)));
-		const ctrl = keys.includes('ctrl');
-		const alt = keys.includes('alt');
-		const shift = keys.includes('shift');
-		return { which, ctrl, alt, shift } as const satisfies Pattern;
-	});
-};
-
-const parseCallback = (rawCallback: Keymap[keyof Keymap]) => {
-	if (typeof rawCallback === 'object') {
-		return rawCallback.callback;
-	}
-	return rawCallback;
-};
-
-const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
-	const defaultOptions = {
-		allowRepeat: false,
-	} as const satisfies Action['options'];
-	if (typeof rawCallback === 'object') {
-		const { callback, ...rawOptions } = rawCallback;
-		const options = { ...defaultOptions, ...rawOptions };
-		return { ...options } as const satisfies Action['options'];
-	}
-	return { ...defaultOptions } as const satisfies Action['options'];
-};
-
-const matchPatterns = (ev: KeyboardEvent, action: Action) => {
-	const { patterns, options, callback } = action;
-	if (ev.repeat && !options.allowRepeat) return false;
-	const key = ev.key.toLowerCase();
-	return patterns.some(({ which, ctrl, shift, alt }) => {
-		if (
-			options.allowRepeat === false &&
-			latestHotkey != null &&
-			latestHotkey.which.includes(key) &&
-			latestHotkey.ctrl === ctrl &&
-			latestHotkey.alt === alt &&
-			latestHotkey.shift === shift &&
-			latestHotkey.callback === callback
-		) {
-			return false;
-		}
-		if (!which.includes(key)) return false;
-		if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
-		if (alt !== ev.altKey) return false;
-		if (shift !== ev.shiftKey) return false;
-		return true;
-	});
-};
-
-let lastHotKeyStoreTimer: number | null = null;
-
-const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
-	if (lastHotKeyStoreTimer != null) {
-		clearTimeout(lastHotKeyStoreTimer);
-	}
-
-	latestHotkey = {
-		which: [ev.key.toLowerCase()],
-		ctrl: ev.ctrlKey || ev.metaKey,
-		alt: ev.altKey,
-		shift: ev.shiftKey,
-		callback,
-	};
-
-	lastHotKeyStoreTimer = window.setTimeout(() => {
-		latestHotkey = null;
-	}, 500);
-};
-
-const parseKeyCode = (input?: string | null) => {
-	if (input == null) return [];
-	const raw = getValueByKey(KEY_ALIASES, input);
-	if (raw == null) return [input];
-	if (typeof raw === 'string') return [trimLower(raw)];
-	return raw.map(trimLower);
-};
-
-const getValueByKey = <
-	T extends Record<keyof any, unknown>,
-	K extends keyof T | keyof any,
-	R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
->(obj: T, key: K) => {
-	return obj[key] as R;
-};
-
-const trimLower = (str: string) => str.trim().toLowerCase();
-//#endregion
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
deleted file mode 100644
index 20f51660c7..0000000000
--- a/packages/frontend/src/scripts/idb-proxy.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
-// indexedDBが使えない環境ではlocalStorageを使う
-import {
-	get as iget,
-	set as iset,
-	del as idel,
-} from 'idb-keyval';
-import { miLocalStorage } from '@/local-storage.js';
-
-const PREFIX = 'idbfallback::';
-
-let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
-
-// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
-// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
-// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-expect-error
-if (window.Cypress) {
-	idbAvailable = false;
-	console.log('Cypress detected. It will use localStorage.');
-}
-
-if (idbAvailable) {
-	await iset('idb-test', 'test')
-		.catch(err => {
-			console.error('idb error', err);
-			console.error('indexedDB is unavailable. It will use localStorage.');
-			idbAvailable = false;
-		});
-} else {
-	console.error('indexedDB is unavailable. It will use localStorage.');
-}
-
-export async function get(key: string) {
-	if (idbAvailable) return iget(key);
-	return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
-}
-
-export async function set(key: string, val: any) {
-	if (idbAvailable) return iset(key, val);
-	return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
-}
-
-export async function del(key: string) {
-	if (idbAvailable) return idel(key);
-	return miLocalStorage.removeItem(`${PREFIX}${key}`);
-}
diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts
deleted file mode 100644
index 6adfedcb9f..0000000000
--- a/packages/frontend/src/scripts/idle-render.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => {
-	const start = performance.now();
-	const timeoutId = setTimeout(() => {
-		callback({
-			didTimeout: false, // polyfill でタイムアウト発火することはない
-			timeRemaining() {
-				const diff = performance.now() - start;
-				return Math.max(0, 50 - diff); // <https://www.w3.org/TR/requestidlecallback/#idle-periods>
-			},
-		});
-	});
-	return timeoutId;
-});
-const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => {
-	clearTimeout(timeoutId);
-});
-
-class IdlingRenderScheduler {
-	#renderers: Set<FrameRequestCallback>;
-	#rafId: number;
-	#ricId: number;
-
-	constructor() {
-		this.#renderers = new Set();
-		this.#rafId = 0;
-		this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
-	}
-
-	#schedule(deadline: IdleDeadline): void {
-		if (deadline.timeRemaining()) {
-			this.#rafId = requestAnimationFrame((time) => {
-				for (const renderer of this.#renderers) {
-					renderer(time);
-				}
-			});
-		}
-		this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
-	}
-
-	add(renderer: FrameRequestCallback): void {
-		this.#renderers.add(renderer);
-	}
-
-	delete(renderer: FrameRequestCallback): void {
-		this.#renderers.delete(renderer);
-	}
-
-	dispose(): void {
-		this.#renderers.clear();
-		cancelAnimationFrame(this.#rafId);
-		cancelIdleCallback(this.#ricId);
-	}
-}
-
-export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();
diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts
deleted file mode 100644
index 037b0d9567..0000000000
--- a/packages/frontend/src/scripts/init-chart.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	DoughnutController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
-import gradient from 'chartjs-plugin-gradient';
-import zoomPlugin from 'chartjs-plugin-zoom';
-import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
-import { store } from '@/store.js';
-import 'chartjs-adapter-date-fns';
-
-export function initChart() {
-	Chart.register(
-		ArcElement,
-		LineElement,
-		BarElement,
-		PointElement,
-		BarController,
-		LineController,
-		DoughnutController,
-		CategoryScale,
-		LinearScale,
-		TimeScale,
-		Legend,
-		Title,
-		Tooltip,
-		SubTitle,
-		Filler,
-		MatrixController, MatrixElement,
-		zoomPlugin,
-		gradient,
-	);
-
-	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
-
-	Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
-	Chart.defaults.animation = false;
-}
diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts
deleted file mode 100644
index 867ebf19ed..0000000000
--- a/packages/frontend/src/scripts/initialize-sw.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { lang } from '@@/js/config.js';
-
-export async function initializeSw() {
-	if (!('serviceWorker' in navigator)) return;
-
-	navigator.serviceWorker.register('/sw.js', { scope: '/', type: 'classic' });
-	navigator.serviceWorker.ready.then(registration => {
-		registration.active?.postMessage({
-			msg: 'initialize',
-			lang,
-		});
-	});
-}
diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/scripts/intl-const.ts
deleted file mode 100644
index 385f59ec39..0000000000
--- a/packages/frontend/src/scripts/intl-const.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { lang } from '@@/js/config.js';
-
-export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
-
-let _dateTimeFormat: Intl.DateTimeFormat;
-try {
-	_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
-		year: 'numeric',
-		month: 'numeric',
-		day: 'numeric',
-		hour: 'numeric',
-		minute: 'numeric',
-		second: 'numeric',
-	});
-} catch (err) {
-	console.warn(err);
-	if (_DEV_) console.log('[Intl] Fallback to en-US');
-
-	// Fallback to en-US
-	_dateTimeFormat = new Intl.DateTimeFormat('en-US', {
-		year: 'numeric',
-		month: 'numeric',
-		day: 'numeric',
-		hour: 'numeric',
-		minute: 'numeric',
-		second: 'numeric',
-	});
-}
-export const dateTimeFormat = _dateTimeFormat;
-
-export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
-
-export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
-
-let _numberFormat: Intl.NumberFormat;
-try {
-	_numberFormat = new Intl.NumberFormat(versatileLang);
-} catch (err) {
-	console.warn(err);
-	if (_DEV_) console.log('[Intl] Fallback to en-US');
-
-	// Fallback to en-US
-	_numberFormat = new Intl.NumberFormat('en-US');
-}
-export const numberFormat = _numberFormat;
diff --git a/packages/frontend/src/scripts/intl-string.ts b/packages/frontend/src/scripts/intl-string.ts
deleted file mode 100644
index a5b5bbb592..0000000000
--- a/packages/frontend/src/scripts/intl-string.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { versatileLang } from '@@/js/intl-const.js';
-import type { toHiragana as toHiraganaType } from 'wanakana';
-
-let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
-let isWanakanaLoaded = false;
-
-/** 
- * ローマ字変換のセットアップ（日本語以外の環境で読み込まないのでlazy-loading）
- * 
- * ここの比較系関数を使う際は事前に呼び出す必要がある
- */
-export async function initIntlString(forceWanakana = false) {
-	if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
-	const { toHiragana: _toHiragana } = await import('wanakana');
-	toHiragana = _toHiragana;
-	isWanakanaLoaded = true;
-}
-
-/**
- * - 全角英数字を半角に
- * - 半角カタカナを全角に
- * - 濁点・半濁点がリガチャになっている（例: `か` ＋ `゛` ）ひらがな・カタカナを結合
- * - 異体字を正規化
- * - 小文字に揃える
- * - 文字列のトリム
- */
-export function normalizeString(str: string) {
-	const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
-	return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
-}
-
-// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
-const hyphens = [
-	0x002d, // hyphen-minus
-	0x02d7, // modifier letter minus sign
-	0x1173, // hangul jongseong eu
-	0x1680, // ogham space mark
-	0x1b78, // balinese musical symbol left-hand open pang
-	0x2010, // hyphen
-	0x2011, // non-breaking hyphen
-	0x2012, // figure dash
-	0x2013, // en dash
-	0x2014, // em dash
-	0x2015, // horizontal bar
-	0x2043, // hyphen bullet
-	0x207b, // superscript minus
-	0x2212, // minus sign
-	0x25ac, // black rectangle
-	0x2500, // box drawings light horizontal
-	0x2501, // box drawings heavy horizontal
-	0x2796, // heavy minus sign
-	0x30fc, // katakana-hiragana prolonged sound mark
-	0x3161, // hangul letter eu
-	0xfe58, // small em dash
-	0xfe63, // small hyphen-minus
-	0xff0d, // fullwidth hyphen-minus
-	0xff70, // halfwidth katakana-hiragana prolonged sound mark
-	0x10110, // aegean number ten
-	0x10191, // roman uncia sign
-];
-
-const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
-
-/** ハイフンを統一（ローマ字半角入力時に`ー`と`-`が判定できない問題の調整） */
-export function normalizeHyphens(str: string) {
-	return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
-}
-
-/**
- * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
- *
- * （ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり）
- */
-export function normalizeStringWithHiragana(str: string) {
-	return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
-}
-
-/** aとbが同じかどうか */
-export function compareStringEquals(a: string, b: string) {
-	return (
-		normalizeString(a) === normalizeString(b) ||
-		normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
-	);
-}
-
-/** baseにqueryが含まれているかどうか */
-export function compareStringIncludes(base: string, query: string) {
-	return (
-		normalizeString(base).includes(normalizeString(query)) ||
-		normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
-	);
-}
diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts
deleted file mode 100644
index 4f487c7cb9..0000000000
--- a/packages/frontend/src/scripts/is-device-darkmode.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function isDeviceDarkmode() {
-	return window.matchMedia('(prefers-color-scheme: dark)').matches;
-}
diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts
deleted file mode 100644
index e28e5725bc..0000000000
--- a/packages/frontend/src/scripts/isFfVisibleForMe.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { $i } from '@/account.js';
-
-export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
-	if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
-
-	if (user.followingVisibility === 'private') return false;
-	if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
-
-	return true;
-}
-export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
-	if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
-
-	if (user.followersVisibility === 'private') return false;
-	if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
-
-	return true;
-}
diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts
deleted file mode 100644
index 020a6c2174..0000000000
--- a/packages/frontend/src/scripts/key-event.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
- * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
- */
-export type KeyCode = (
-	| 'Backspace'
-	| 'Tab'
-	| 'Enter'
-	| 'Shift'
-	| 'Control'
-	| 'Alt'
-	| 'Pause'
-	| 'CapsLock'
-	| 'Escape'
-	| 'Space'
-	| 'PageUp'
-	| 'PageDown'
-	| 'End'
-	| 'Home'
-	| 'ArrowLeft'
-	| 'ArrowUp'
-	| 'ArrowRight'
-	| 'ArrowDown'
-	| 'Insert'
-	| 'Delete'
-	| 'Digit0'
-	| 'Digit1'
-	| 'Digit2'
-	| 'Digit3'
-	| 'Digit4'
-	| 'Digit5'
-	| 'Digit6'
-	| 'Digit7'
-	| 'Digit8'
-	| 'Digit9'
-	| 'KeyA'
-	| 'KeyB'
-	| 'KeyC'
-	| 'KeyD'
-	| 'KeyE'
-	| 'KeyF'
-	| 'KeyG'
-	| 'KeyH'
-	| 'KeyI'
-	| 'KeyJ'
-	| 'KeyK'
-	| 'KeyL'
-	| 'KeyM'
-	| 'KeyN'
-	| 'KeyO'
-	| 'KeyP'
-	| 'KeyQ'
-	| 'KeyR'
-	| 'KeyS'
-	| 'KeyT'
-	| 'KeyU'
-	| 'KeyV'
-	| 'KeyW'
-	| 'KeyX'
-	| 'KeyY'
-	| 'KeyZ'
-	| 'MetaLeft'
-	| 'MetaRight'
-	| 'ContextMenu'
-	| 'F1'
-	| 'F2'
-	| 'F3'
-	| 'F4'
-	| 'F5'
-	| 'F6'
-	| 'F7'
-	| 'F8'
-	| 'F9'
-	| 'F10'
-	| 'F11'
-	| 'F12'
-	| 'NumLock'
-	| 'ScrollLock'
-	| 'Semicolon'
-	| 'Equal'
-	| 'Comma'
-	| 'Minus'
-	| 'Period'
-	| 'Slash'
-	| 'Backquote'
-	| 'BracketLeft'
-	| 'Backslash'
-	| 'BracketRight'
-	| 'Quote'
-	| 'Meta'
-	| 'AltGraph'
-);
-
-/**
- * 修飾キーを表す文字列。不足分は適宜追加する。
- */
-export type KeyModifier = (
-	| 'Shift'
-	| 'Control'
-	| 'Alt'
-	| 'Meta'
-);
-
-/**
- * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
- */
-export type KeyState = (
-	| 'composing'
-	| 'repeat'
-);
-
-export type KeyEventHandler = {
-	modifiers?: KeyModifier[];
-	states?: KeyState[];
-	code: KeyCode | 'any';
-	handler: (event: KeyboardEvent) => void;
-};
-
-export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
-	function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
-		if (modifiers) {
-			return modifiers.every(modifier => ev.getModifierState(modifier));
-		}
-		return true;
-	}
-
-	function checkState(ev: KeyboardEvent, states?: KeyState[]) {
-		if (states) {
-			return states.every(state => ev.getModifierState(state));
-		}
-		return true;
-	}
-
-	let hit = false;
-	for (const handler of handlers.filter(it => it.code === event.code)) {
-		if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
-			handler.handler(event);
-			hit = true;
-			break;
-		}
-	}
-
-	if (!hit) {
-		for (const handler of handlers.filter(it => it.code === 'any')) {
-			handler.handler(event);
-		}
-	}
-}
diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts
deleted file mode 100644
index b32de15963..0000000000
--- a/packages/frontend/src/scripts/langmap.ts
+++ /dev/null
@@ -1,671 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// TODO: sharedに置いてバックエンドのと統合したい
-export const langmap = {
-	'ach': {
-		nativeName: 'Lwo',
-	},
-	'ady': {
-		nativeName: 'Адыгэбзэ',
-	},
-	'af': {
-		nativeName: 'Afrikaans',
-	},
-	'af-NA': {
-		nativeName: 'Afrikaans (Namibia)',
-	},
-	'af-ZA': {
-		nativeName: 'Afrikaans (South Africa)',
-	},
-	'ak': {
-		nativeName: 'Tɕɥi',
-	},
-	'ar': {
-		nativeName: 'العربية',
-	},
-	'ar-AR': {
-		nativeName: 'العربية',
-	},
-	'ar-MA': {
-		nativeName: 'العربية',
-	},
-	'ar-SA': {
-		nativeName: 'العربية (السعودية)',
-	},
-	'ay-BO': {
-		nativeName: 'Aymar aru',
-	},
-	'az': {
-		nativeName: 'Azərbaycan dili',
-	},
-	'az-AZ': {
-		nativeName: 'Azərbaycan dili',
-	},
-	'be-BY': {
-		nativeName: 'Беларуская',
-	},
-	'bg': {
-		nativeName: 'Български',
-	},
-	'bg-BG': {
-		nativeName: 'Български',
-	},
-	'bn': {
-		nativeName: 'বাংলা',
-	},
-	'bn-IN': {
-		nativeName: 'বাংলা (ভারত)',
-	},
-	'bn-BD': {
-		nativeName: 'বাংলা(বাংলাদেশ)',
-	},
-	'br': {
-		nativeName: 'Brezhoneg',
-	},
-	'bs-BA': {
-		nativeName: 'Bosanski',
-	},
-	'ca': {
-		nativeName: 'Català',
-	},
-	'ca-ES': {
-		nativeName: 'Català',
-	},
-	'cak': {
-		nativeName: 'Maya Kaqchikel',
-	},
-	'ck-US': {
-		nativeName: 'ᏣᎳᎩ (tsalagi)',
-	},
-	'cs': {
-		nativeName: 'Čeština',
-	},
-	'cs-CZ': {
-		nativeName: 'Čeština',
-	},
-	'cy': {
-		nativeName: 'Cymraeg',
-	},
-	'cy-GB': {
-		nativeName: 'Cymraeg',
-	},
-	'da': {
-		nativeName: 'Dansk',
-	},
-	'da-DK': {
-		nativeName: 'Dansk',
-	},
-	'de': {
-		nativeName: 'Deutsch',
-	},
-	'de-AT': {
-		nativeName: 'Deutsch (Österreich)',
-	},
-	'de-DE': {
-		nativeName: 'Deutsch (Deutschland)',
-	},
-	'de-CH': {
-		nativeName: 'Deutsch (Schweiz)',
-	},
-	'dsb': {
-		nativeName: 'Dolnoserbšćina',
-	},
-	'el': {
-		nativeName: 'Ελληνικά',
-	},
-	'el-GR': {
-		nativeName: 'Ελληνικά',
-	},
-	'en': {
-		nativeName: 'English',
-	},
-	'en-GB': {
-		nativeName: 'English (UK)',
-	},
-	'en-AU': {
-		nativeName: 'English (Australia)',
-	},
-	'en-CA': {
-		nativeName: 'English (Canada)',
-	},
-	'en-IE': {
-		nativeName: 'English (Ireland)',
-	},
-	'en-IN': {
-		nativeName: 'English (India)',
-	},
-	'en-PI': {
-		nativeName: 'English (Pirate)',
-	},
-	'en-SG': {
-		nativeName: 'English (Singapore)',
-	},
-	'en-UD': {
-		nativeName: 'English (Upside Down)',
-	},
-	'en-US': {
-		nativeName: 'English (US)',
-	},
-	'en-ZA': {
-		nativeName: 'English (South Africa)',
-	},
-	'en@pirate': {
-		nativeName: 'English (Pirate)',
-	},
-	'eo': {
-		nativeName: 'Esperanto',
-	},
-	'eo-EO': {
-		nativeName: 'Esperanto',
-	},
-	'es': {
-		nativeName: 'Español',
-	},
-	'es-AR': {
-		nativeName: 'Español (Argentine)',
-	},
-	'es-419': {
-		nativeName: 'Español (Latinoamérica)',
-	},
-	'es-CL': {
-		nativeName: 'Español (Chile)',
-	},
-	'es-CO': {
-		nativeName: 'Español (Colombia)',
-	},
-	'es-EC': {
-		nativeName: 'Español (Ecuador)',
-	},
-	'es-ES': {
-		nativeName: 'Español (España)',
-	},
-	'es-LA': {
-		nativeName: 'Español (Latinoamérica)',
-	},
-	'es-NI': {
-		nativeName: 'Español (Nicaragua)',
-	},
-	'es-MX': {
-		nativeName: 'Español (México)',
-	},
-	'es-US': {
-		nativeName: 'Español (Estados Unidos)',
-	},
-	'es-VE': {
-		nativeName: 'Español (Venezuela)',
-	},
-	'et': {
-		nativeName: 'eesti keel',
-	},
-	'et-EE': {
-		nativeName: 'Eesti (Estonia)',
-	},
-	'eu': {
-		nativeName: 'Euskara',
-	},
-	'eu-ES': {
-		nativeName: 'Euskara',
-	},
-	'fa': {
-		nativeName: 'فارسی',
-	},
-	'fa-IR': {
-		nativeName: 'فارسی',
-	},
-	'fb-LT': {
-		nativeName: 'Leet Speak',
-	},
-	'ff': {
-		nativeName: 'Fulah',
-	},
-	'fi': {
-		nativeName: 'Suomi',
-	},
-	'fi-FI': {
-		nativeName: 'Suomi',
-	},
-	'fo': {
-		nativeName: 'Føroyskt',
-	},
-	'fo-FO': {
-		nativeName: 'Føroyskt (Færeyjar)',
-	},
-	'fr': {
-		nativeName: 'Français',
-	},
-	'fr-CA': {
-		nativeName: 'Français (Canada)',
-	},
-	'fr-FR': {
-		nativeName: 'Français (France)',
-	},
-	'fr-BE': {
-		nativeName: 'Français (Belgique)',
-	},
-	'fr-CH': {
-		nativeName: 'Français (Suisse)',
-	},
-	'fy-NL': {
-		nativeName: 'Frysk',
-	},
-	'ga': {
-		nativeName: 'Gaeilge',
-	},
-	'ga-IE': {
-		nativeName: 'Gaeilge',
-	},
-	'gd': {
-		nativeName: 'Gàidhlig',
-	},
-	'gl': {
-		nativeName: 'Galego',
-	},
-	'gl-ES': {
-		nativeName: 'Galego',
-	},
-	'gn-PY': {
-		nativeName: 'Avañe\'ẽ',
-	},
-	'gu-IN': {
-		nativeName: 'ગુજરાતી',
-	},
-	'gv': {
-		nativeName: 'Gaelg',
-	},
-	'gx-GR': {
-		nativeName: 'Ἑλληνική ἀρχαία',
-	},
-	'he': {
-		nativeName: 'עברית‏',
-	},
-	'he-IL': {
-		nativeName: 'עברית‏',
-	},
-	'hi': {
-		nativeName: 'हिन्दी',
-	},
-	'hi-IN': {
-		nativeName: 'हिन्दी',
-	},
-	'hr': {
-		nativeName: 'Hrvatski',
-	},
-	'hr-HR': {
-		nativeName: 'Hrvatski',
-	},
-	'hsb': {
-		nativeName: 'Hornjoserbšćina',
-	},
-	'ht': {
-		nativeName: 'Kreyòl',
-	},
-	'hu': {
-		nativeName: 'Magyar',
-	},
-	'hu-HU': {
-		nativeName: 'Magyar',
-	},
-	'hy': {
-		nativeName: 'Հայերեն',
-	},
-	'hy-AM': {
-		nativeName: 'Հայերեն (Հայաստան)',
-	},
-	'id': {
-		nativeName: 'Bahasa Indonesia',
-	},
-	'id-ID': {
-		nativeName: 'Bahasa Indonesia',
-	},
-	'is': {
-		nativeName: 'Íslenska',
-	},
-	'is-IS': {
-		nativeName: 'Íslenska (Iceland)',
-	},
-	'it': {
-		nativeName: 'Italiano',
-	},
-	'it-IT': {
-		nativeName: 'Italiano',
-	},
-	'ja': {
-		nativeName: '日本語',
-	},
-	'ja-JP': {
-		nativeName: '日本語 (日本)',
-	},
-	'jv-ID': {
-		nativeName: 'Basa Jawa',
-	},
-	'ka-GE': {
-		nativeName: 'ქართული',
-	},
-	'kk-KZ': {
-		nativeName: 'Қазақша',
-	},
-	'km': {
-		nativeName: 'ភាសាខ្មែរ',
-	},
-	'kl': {
-		nativeName: 'kalaallisut',
-	},
-	'km-KH': {
-		nativeName: 'ភាសាខ្មែរ',
-	},
-	'kab': {
-		nativeName: 'Taqbaylit',
-	},
-	'kn': {
-		nativeName: 'ಕನ್ನಡ',
-	},
-	'kn-IN': {
-		nativeName: 'ಕನ್ನಡ (India)',
-	},
-	'ko': {
-		nativeName: '한국어',
-	},
-	'ko-KR': {
-		nativeName: '한국어 (한국)',
-	},
-	'ku-TR': {
-		nativeName: 'Kurdî',
-	},
-	'kw': {
-		nativeName: 'Kernewek',
-	},
-	'la': {
-		nativeName: 'Latin',
-	},
-	'la-VA': {
-		nativeName: 'Latin',
-	},
-	'lb': {
-		nativeName: 'Lëtzebuergesch',
-	},
-	'li-NL': {
-		nativeName: 'Lèmbörgs',
-	},
-	'lt': {
-		nativeName: 'Lietuvių',
-	},
-	'lt-LT': {
-		nativeName: 'Lietuvių',
-	},
-	'lv': {
-		nativeName: 'Latviešu',
-	},
-	'lv-LV': {
-		nativeName: 'Latviešu',
-	},
-	'mai': {
-		nativeName: 'मैथिली, মৈথিলী',
-	},
-	'mg-MG': {
-		nativeName: 'Malagasy',
-	},
-	'mk': {
-		nativeName: 'Македонски',
-	},
-	'mk-MK': {
-		nativeName: 'Македонски (Македонски)',
-	},
-	'ml': {
-		nativeName: 'മലയാളം',
-	},
-	'ml-IN': {
-		nativeName: 'മലയാളം',
-	},
-	'mn-MN': {
-		nativeName: 'Монгол',
-	},
-	'mr': {
-		nativeName: 'मराठी',
-	},
-	'mr-IN': {
-		nativeName: 'मराठी',
-	},
-	'ms': {
-		nativeName: 'Bahasa Melayu',
-	},
-	'ms-MY': {
-		nativeName: 'Bahasa Melayu',
-	},
-	'mt': {
-		nativeName: 'Malti',
-	},
-	'mt-MT': {
-		nativeName: 'Malti',
-	},
-	'my': {
-		nativeName: 'ဗမာစကာ',
-	},
-	'no': {
-		nativeName: 'Norsk',
-	},
-	'nb': {
-		nativeName: 'Norsk (bokmål)',
-	},
-	'nb-NO': {
-		nativeName: 'Norsk (bokmål)',
-	},
-	'ne': {
-		nativeName: 'नेपाली',
-	},
-	'ne-NP': {
-		nativeName: 'नेपाली',
-	},
-	'nl': {
-		nativeName: 'Nederlands',
-	},
-	'nl-BE': {
-		nativeName: 'Nederlands (België)',
-	},
-	'nl-NL': {
-		nativeName: 'Nederlands (Nederland)',
-	},
-	'nn-NO': {
-		nativeName: 'Norsk (nynorsk)',
-	},
-	'oc': {
-		nativeName: 'Occitan',
-	},
-	'or-IN': {
-		nativeName: 'ଓଡ଼ିଆ',
-	},
-	'pa': {
-		nativeName: 'ਪੰਜਾਬੀ',
-	},
-	'pa-IN': {
-		nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
-	},
-	'pl': {
-		nativeName: 'Polski',
-	},
-	'pl-PL': {
-		nativeName: 'Polski',
-	},
-	'ps-AF': {
-		nativeName: 'پښتو',
-	},
-	'pt': {
-		nativeName: 'Português',
-	},
-	'pt-BR': {
-		nativeName: 'Português (Brasil)',
-	},
-	'pt-PT': {
-		nativeName: 'Português (Portugal)',
-	},
-	'qu-PE': {
-		nativeName: 'Qhichwa',
-	},
-	'rm-CH': {
-		nativeName: 'Rumantsch',
-	},
-	'ro': {
-		nativeName: 'Română',
-	},
-	'ro-RO': {
-		nativeName: 'Română',
-	},
-	'ru': {
-		nativeName: 'Русский',
-	},
-	'ru-RU': {
-		nativeName: 'Русский',
-	},
-	'sa-IN': {
-		nativeName: 'संस्कृतम्',
-	},
-	'se-NO': {
-		nativeName: 'Davvisámegiella',
-	},
-	'sh': {
-		nativeName: 'српскохрватски',
-	},
-	'si-LK': {
-		nativeName: 'සිංහල',
-	},
-	'sk': {
-		nativeName: 'Slovenčina',
-	},
-	'sk-SK': {
-		nativeName: 'Slovenčina (Slovakia)',
-	},
-	'sl': {
-		nativeName: 'Slovenščina',
-	},
-	'sl-SI': {
-		nativeName: 'Slovenščina',
-	},
-	'so-SO': {
-		nativeName: 'Soomaaliga',
-	},
-	'sq': {
-		nativeName: 'Shqip',
-	},
-	'sq-AL': {
-		nativeName: 'Shqip',
-	},
-	'sr': {
-		nativeName: 'Српски',
-	},
-	'sr-RS': {
-		nativeName: 'Српски (Serbia)',
-	},
-	'su': {
-		nativeName: 'Basa Sunda',
-	},
-	'sv': {
-		nativeName: 'Svenska',
-	},
-	'sv-SE': {
-		nativeName: 'Svenska',
-	},
-	'sw': {
-		nativeName: 'Kiswahili',
-	},
-	'sw-KE': {
-		nativeName: 'Kiswahili',
-	},
-	'ta': {
-		nativeName: 'தமிழ்',
-	},
-	'ta-IN': {
-		nativeName: 'தமிழ்',
-	},
-	'te': {
-		nativeName: 'తెలుగు',
-	},
-	'te-IN': {
-		nativeName: 'తెలుగు',
-	},
-	'tg': {
-		nativeName: 'забо́ни тоҷикӣ́',
-	},
-	'tg-TJ': {
-		nativeName: 'тоҷикӣ',
-	},
-	'th': {
-		nativeName: 'ภาษาไทย',
-	},
-	'th-TH': {
-		nativeName: 'ภาษาไทย (ประเทศไทย)',
-	},
-	'fil': {
-		nativeName: 'Filipino',
-	},
-	'tlh': {
-		nativeName: 'tlhIngan-Hol',
-	},
-	'tr': {
-		nativeName: 'Türkçe',
-	},
-	'tr-TR': {
-		nativeName: 'Türkçe',
-	},
-	'tt-RU': {
-		nativeName: 'татарча',
-	},
-	'uk': {
-		nativeName: 'Українська',
-	},
-	'uk-UA': {
-		nativeName: 'Українська',
-	},
-	'ur': {
-		nativeName: 'اردو',
-	},
-	'ur-PK': {
-		nativeName: 'اردو',
-	},
-	'uz': {
-		nativeName: 'O\'zbek',
-	},
-	'uz-UZ': {
-		nativeName: 'O\'zbek',
-	},
-	'vi': {
-		nativeName: 'Tiếng Việt',
-	},
-	'vi-VN': {
-		nativeName: 'Tiếng Việt',
-	},
-	'xh-ZA': {
-		nativeName: 'isiXhosa',
-	},
-	'yi': {
-		nativeName: 'ייִדיש',
-	},
-	'yi-DE': {
-		nativeName: 'ייִדיש (German)',
-	},
-	'zh': {
-		nativeName: '中文',
-	},
-	'zh-Hans': {
-		nativeName: '中文简体',
-	},
-	'zh-Hant': {
-		nativeName: '中文繁體',
-	},
-	'zh-CN': {
-		nativeName: '中文（中国大陆）',
-	},
-	'zh-HK': {
-		nativeName: '中文（香港）',
-	},
-	'zh-SG': {
-		nativeName: '中文（新加坡）',
-	},
-	'zh-TW': {
-		nativeName: '中文（台灣）',
-	},
-	'zu-ZA': {
-		nativeName: 'isiZulu',
-	},
-};
diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts
deleted file mode 100644
index b52735caa0..0000000000
--- a/packages/frontend/src/scripts/login-id.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function getUrlWithLoginId(url: string, loginId: string) {
-	const u = new URL(url, origin);
-	u.searchParams.append('loginId', loginId);
-	return u.toString();
-}
-
-export function getUrlWithoutLoginId(url: string) {
-	const u = new URL(url);
-	u.searchParams.delete('loginId');
-	return u.toString();
-}
diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts
deleted file mode 100644
index 02f589c7ca..0000000000
--- a/packages/frontend/src/scripts/lookup.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { i18n } from '@/i18n.js';
-import { Router } from '@/nirax.js';
-import { mainRouter } from '@/router/main.js';
-
-export async function lookup(router?: Router) {
-	const _router = router ?? mainRouter;
-
-	const { canceled, result: temp } = await os.inputText({
-		title: i18n.ts.lookup,
-	});
-	const query = temp ? temp.trim() : '';
-	if (canceled || query.length <= 1) return;
-
-	if (query.startsWith('@') && !query.includes(' ')) {
-		_router.push(`/${query}`);
-		return;
-	}
-
-	if (query.startsWith('#')) {
-		_router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
-		return;
-	}
-
-	if (query.startsWith('https://')) {
-		const res = await apLookup(query);
-
-		if (res.type === 'User') {
-			_router.push(`/@${res.object.username}@${res.object.host}`);
-		} else if (res.type === 'Note') {
-			_router.push(`/notes/${res.object.id}`);
-		}
-
-		return;
-	}
-}
-
-export async function apLookup(query: string) {
-	const promise = misskeyApi('ap/show', {
-		uri: query,
-	});
-
-	os.promiseDialog(promise, null, (err) => {
-		let title = i18n.ts.somethingHappened;
-		let text = err.message + '\n' + err.id;
-
-		switch (err.id) {
-			case '974b799e-1a29-4889-b706-18d4dd93e266':
-				title = i18n.ts._remoteLookupErrors._federationNotAllowed.title;
-				text = i18n.ts._remoteLookupErrors._federationNotAllowed.description;
-				break;
-			case '1a5eab56-e47b-48c2-8d5e-217b897d70db':
-				title = i18n.ts._remoteLookupErrors._uriInvalid.title;
-				text = i18n.ts._remoteLookupErrors._uriInvalid.description;
-				break;
-			case '81b539cf-4f57-4b29-bc98-032c33c0792e':
-				title = i18n.ts._remoteLookupErrors._requestFailed.title;
-				text = i18n.ts._remoteLookupErrors._requestFailed.description;
-				break;
-			case '70193c39-54f3-4813-82f0-70a680f7495b':
-				title = i18n.ts._remoteLookupErrors._responseInvalid.title;
-				text = i18n.ts._remoteLookupErrors._responseInvalid.description;
-				break;
-			case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
-				title = i18n.ts._remoteLookupErrors._noSuchObject.title;
-				text = i18n.ts._remoteLookupErrors._noSuchObject.description;
-				break;
-		}
-
-		os.alert({
-			type: 'error',
-			title,
-			text,
-		});
-	}, i18n.ts.fetchingAsApObject);
-
-	return await promise;
-}
diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/scripts/media-has-audio.ts
deleted file mode 100644
index 4bf3ee5d97..0000000000
--- a/packages/frontend/src/scripts/media-has-audio.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export default async function hasAudio(media: HTMLMediaElement) {
-	const cloned = media.cloneNode() as HTMLMediaElement;
-	cloned.muted = (cloned as typeof cloned & Partial<HTMLVideoElement>).playsInline = true;
-	cloned.play();
-	await new Promise((resolve) => cloned.addEventListener('playing', resolve));
-	const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount;
-	cloned.remove();
-	return result;
-}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
deleted file mode 100644
index 78eba35ead..0000000000
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { MediaProxy } from '@@/js/media-proxy.js';
-import { url } from '@@/js/config.js';
-import { instance } from '@/instance.js';
-
-let _mediaProxy: MediaProxy | null = null;
-
-export function getProxiedImageUrl(...args: Parameters<MediaProxy['getProxiedImageUrl']>): string {
-	if (_mediaProxy == null) {
-		_mediaProxy = new MediaProxy(instance, url);
-	}
-
-	return _mediaProxy.getProxiedImageUrl(...args);
-}
-
-export function getProxiedImageUrlNullable(...args: Parameters<MediaProxy['getProxiedImageUrlNullable']>): string | null {
-	if (_mediaProxy == null) {
-		_mediaProxy = new MediaProxy(instance, url);
-	}
-
-	return _mediaProxy.getProxiedImageUrlNullable(...args);
-}
-
-export function getStaticImageUrl(...args: Parameters<MediaProxy['getStaticImageUrl']>): string {
-	if (_mediaProxy == null) {
-		_mediaProxy = new MediaProxy(instance, url);
-	}
-
-	return _mediaProxy.getStaticImageUrl(...args);
-}
diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts
deleted file mode 100644
index 004b6d42a4..0000000000
--- a/packages/frontend/src/scripts/merge.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { deepClone } from './clone.js';
-import type { Cloneable } from './clone.js';
-
-export type DeepPartial<T> = {
-	[P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P];
-};
-
-function isPureObject(value: unknown): value is Record<PropertyKey, unknown> {
-	return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-/**
- * valueにないキーをdefからもらう（再帰的）\
- * nullはそのまま、undefinedはdefの値
- **/
-export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X {
-	if (isPureObject(value) && isPureObject(def)) {
-		const result = deepClone(value as Cloneable) as X;
-		for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
-			if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
-				result[k] = v;
-			} else if (isPureObject(v) && isPureObject(result[k])) {
-				const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>;
-				result[k] = deepMerge<typeof v>(child, v);
-			}
-		}
-		return result;
-	}
-	throw new Error('deepMerge: value and def must be pure objects');
-}
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
deleted file mode 100644
index a2f777f623..0000000000
--- a/packages/frontend/src/scripts/mfm-function-picker.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { nextTick } from 'vue';
-import type { Ref } from 'vue';
-import * as os from '@/os.js';
-import { i18n } from '@/i18n.js';
-import { MFM_TAGS } from '@@/js/const.js';
-import type { MenuItem } from '@/types/menu.js';
-
-/**
- * MFMの装飾のリストを表示する
- */
-export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
-	os.popupMenu([{
-		text: i18n.ts.addMfmFunction,
-		type: 'label',
-	}, ...getFunctionList(textArea, textRef)], src);
-}
-
-function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] {
-	return MFM_TAGS.map(tag => ({
-		text: tag,
-		icon: 'ti ti-icons',
-		action: () => add(textArea, textRef, tag),
-	}));
-}
-
-function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) {
-	const caretStart: number = textArea.selectionStart as number;
-	const caretEnd: number = textArea.selectionEnd as number;
-
-	MFM_TAGS.forEach(tag => {
-		if (type === tag) {
-			if (caretStart === caretEnd) {
-				// 単純にFunctionを追加
-				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
-				textRef.value = trimmedText;
-			} else {
-				// 選択範囲を囲むようにFunctionを追加
-				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
-				textRef.value = trimmedText;
-			}
-		}
-	});
-
-	const nextCaretStart: number = caretStart + 3 + type.length;
-	const nextCaretEnd: number = caretEnd + 3 + type.length;
-
-	// キャレットを戻す
-	nextTick(() => {
-		textArea.focus();
-		textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
-	});
-}
diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts
deleted file mode 100644
index dc07ad477b..0000000000
--- a/packages/frontend/src/scripts/misskey-api.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { ref } from 'vue';
-import { apiUrl } from '@@/js/config.js';
-import { $i } from '@/account.js';
-export const pendingApiRequestsCount = ref(0);
-
-export type Endpoint = keyof Misskey.Endpoints;
-
-export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req'];
-
-export type AnyRequest<E extends Endpoint | (string & unknown)> =
-	(E extends Endpoint ? Request<E> : never) | object;
-
-export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> =
-	E extends Endpoint
-	? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never
-	: object;
-
-// Implements Misskey.api.ApiClient.request
-export function misskeyApi<
-	ResT = void,
-	E extends Endpoint | NonNullable<string> = Endpoint,
-	P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never,
-	_ResT = ResT extends void ? Response<E, P> : ResT,
->(
-	endpoint: E,
-	data: P & { i?: string | null; } = {} as any,
-	token?: string | null | undefined,
-	signal?: AbortSignal,
-): Promise<_ResT> {
-	if (endpoint.includes('://')) throw new Error('invalid endpoint');
-	pendingApiRequestsCount.value++;
-
-	const onFinally = () => {
-		pendingApiRequestsCount.value--;
-	};
-
-	const promise = new Promise<_ResT>((resolve, reject) => {
-		// Append a credential
-		if ($i) data.i = $i.token;
-		if (token !== undefined) data.i = token;
-
-		// Send request
-		window.fetch(`${apiUrl}/${endpoint}`, {
-			method: 'POST',
-			body: JSON.stringify(data),
-			credentials: 'omit',
-			cache: 'no-cache',
-			headers: {
-				'Content-Type': 'application/json',
-			},
-			signal,
-		}).then(async (res) => {
-			const body = res.status === 204 ? null : await res.json();
-
-			if (res.status === 200) {
-				resolve(body);
-			} else if (res.status === 204) {
-				resolve(undefined as _ResT); // void -> undefined
-			} else {
-				reject(body.error);
-			}
-		}).catch(reject);
-	});
-
-	promise.then(onFinally, onFinally);
-
-	return promise;
-}
-
-// Implements Misskey.api.ApiClient.request
-export function misskeyApiGet<
-	ResT = void,
-	E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
-	P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
-	_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
->(
-	endpoint: E,
-	data: P = {} as any,
-): Promise<_ResT> {
-	pendingApiRequestsCount.value++;
-
-	const onFinally = () => {
-		pendingApiRequestsCount.value--;
-	};
-
-	const query = new URLSearchParams(data as any);
-
-	const promise = new Promise<_ResT>((resolve, reject) => {
-		// Send request
-		window.fetch(`${apiUrl}/${endpoint}?${query}`, {
-			method: 'GET',
-			credentials: 'omit',
-			cache: 'default',
-		}).then(async (res) => {
-			const body = res.status === 204 ? null : await res.json();
-
-			if (res.status === 200) {
-				resolve(body);
-			} else if (res.status === 204) {
-				resolve(undefined as _ResT); // void -> undefined
-			} else {
-				reject(body.error);
-			}
-		}).catch(reject);
-	});
-
-	promise.then(onFinally, onFinally);
-
-	return promise;
-}
diff --git a/packages/frontend/src/scripts/navigator.ts b/packages/frontend/src/scripts/navigator.ts
deleted file mode 100644
index ffc0a457f4..0000000000
--- a/packages/frontend/src/scripts/navigator.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function isSupportShare(): boolean {
-	return 'share' in navigator;
-}
diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts
deleted file mode 100644
index 671751147c..0000000000
--- a/packages/frontend/src/scripts/page-metadata.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue';
-import type { MaybeRefOrGetter, Ref } from 'vue';
-
-export type PageMetadata = {
-	title: string;
-	subtitle?: string;
-	icon?: string | null;
-	avatar?: Misskey.entities.User | null;
-	userName?: Misskey.entities.User | null;
-	needWideArea?: boolean;
-};
-
-type PageMetadataGetter = () => PageMetadata;
-type PageMetadataReceiver = (getter: PageMetadataGetter) => void;
-
-const RECEIVER_KEY = Symbol('ReceiverKey');
-const setReceiver = (v: PageMetadataReceiver): void => {
-	provide<PageMetadataReceiver>(RECEIVER_KEY, v);
-};
-const getReceiver = (): PageMetadataReceiver | undefined => {
-	return inject<PageMetadataReceiver>(RECEIVER_KEY);
-};
-
-const METADATA_KEY = Symbol('MetadataKey');
-const setMetadata = (v: Ref<PageMetadata | null>): void => {
-	provide<Ref<PageMetadata | null>>(METADATA_KEY, v);
-};
-const getMetadata = (): Ref<PageMetadata | null> | undefined => {
-	return inject<Ref<PageMetadata | null>>(METADATA_KEY);
-};
-
-export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => {
-	const metadataRef = ref(toValue(maybeRefOrGetterMetadata));
-	const metadataGetter = () => metadataRef.value;
-	const receiver = getReceiver();
-
-	// setup handler
-	receiver?.(metadataGetter);
-
-	// update handler
-	onBeforeUnmount(watch(
-		() => toValue(maybeRefOrGetterMetadata),
-		(metadata) => {
-			metadataRef.value = metadata;
-			receiver?.(metadataGetter);
-		},
-		{ deep: true },
-	));
-	onActivated(() => {
-		receiver?.(metadataGetter);
-	});
-};
-
-export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => {
-	setReceiver(receiver);
-};
-
-export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => {
-	setMetadata(metadataRef);
-};
-
-export const injectReactiveMetadata = (): Ref<PageMetadata | null> => {
-	const metadataRef = getMetadata();
-	return isRef(metadataRef) ? metadataRef : ref(null);
-};
diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts
deleted file mode 100644
index 8a4e9319b3..0000000000
--- a/packages/frontend/src/scripts/physics.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Matter from 'matter-js';
-
-export function physics(container: HTMLElement) {
-	const containerWidth = container.offsetWidth;
-	const containerHeight = container.offsetHeight;
-	const containerCenterX = containerWidth / 2;
-
-	// サイズ固定化(要らないかも？)
-	container.style.position = 'relative';
-	container.style.boxSizing = 'border-box';
-	container.style.width = `${containerWidth}px`;
-	container.style.height = `${containerHeight}px`;
-
-	// create engine
-	const engine = Matter.Engine.create({
-		constraintIterations: 4,
-		positionIterations: 8,
-		velocityIterations: 8,
-	});
-
-	const world = engine.world;
-
-	// create renderer
-	const render = Matter.Render.create({
-		engine: engine,
-		//element: document.getElementById('debug'),
-		options: {
-			width: containerWidth,
-			height: containerHeight,
-			background: 'transparent', // transparent to hide
-			wireframeBackground: 'transparent', // transparent to hide
-		},
-	});
-
-	// Disable to hide debug
-	Matter.Render.run(render);
-
-	// create runner
-	const runner = Matter.Runner.create();
-	Matter.Runner.run(runner, engine);
-
-	const groundThickness = 1024;
-	const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
-		isStatic: true,
-		restitution: 0.1,
-		friction: 2,
-	});
-
-	//const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
-	//const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
-
-	Matter.World.add(world, [
-		ground,
-		//wallRight,
-		//wallLeft,
-	]);
-
-	const objEls = Array.from(container.children) as HTMLElement[];
-	const objs: Matter.Body[] = [];
-	for (const objEl of objEls) {
-		const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
-		const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
-
-		let obj: Matter.Body;
-		if (objEl.classList.contains('_physics_circle_')) {
-			obj = Matter.Bodies.circle(
-				left + (objEl.offsetWidth / 2),
-				top + (objEl.offsetHeight / 2),
-				Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
-				{
-					restitution: 0.5,
-				},
-			);
-		} else {
-			const style = window.getComputedStyle(objEl);
-			obj = Matter.Bodies.rectangle(
-				left + (objEl.offsetWidth / 2),
-				top + (objEl.offsetHeight / 2),
-				objEl.offsetWidth,
-				objEl.offsetHeight,
-				{
-					chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
-					restitution: 0.5,
-				},
-			);
-		}
-		objEl.id = obj.id.toString();
-		objs.push(obj);
-	}
-
-	Matter.World.add(engine.world, objs);
-
-	// Add mouse control
-
-	const mouse = Matter.Mouse.create(container);
-	const mouseConstraint = Matter.MouseConstraint.create(engine, {
-		mouse: mouse,
-		constraint: {
-			stiffness: 0.1,
-			render: {
-				visible: false,
-			},
-		},
-	});
-
-	Matter.World.add(engine.world, mouseConstraint);
-
-	// keep the mouse in sync with rendering
-	render.mouse = mouse;
-
-	for (const objEl of objEls) {
-		objEl.style.position = 'absolute';
-		objEl.style.top = '0';
-		objEl.style.left = '0';
-		objEl.style.margin = '0';
-	}
-
-	window.requestAnimationFrame(update);
-
-	let stop = false;
-
-	function update() {
-		for (const objEl of objEls) {
-			const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
-			if (obj == null) continue;
-
-			const x = (obj.position.x - objEl.offsetWidth / 2);
-			const y = (obj.position.y - objEl.offsetHeight / 2);
-			const angle = obj.angle;
-			objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
-		}
-
-		if (!stop) {
-			window.requestAnimationFrame(update);
-		}
-	}
-
-	// 奈落に落ちたオブジェクトは消す
-	const intervalId = window.setInterval(() => {
-		for (const obj of objs) {
-			if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
-		}
-	}, 1000 * 10);
-
-	return {
-		stop: () => {
-			stop = true;
-			Matter.Runner.stop(runner);
-			window.clearInterval(intervalId);
-		},
-	};
-}
diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/scripts/player-url-transform.ts
deleted file mode 100644
index 39c6df6500..0000000000
--- a/packages/frontend/src/scripts/player-url-transform.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { hostname } from '@@/js/config.js';
-
-export function transformPlayerUrl(url: string): string {
-	const urlObj = new URL(url);
-	if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
-
-	const urlParams = new URLSearchParams(urlObj.search);
-
-	if (urlObj.hostname === 'player.twitch.tv') {
-		// TwitchはCSPの制約あり
-		// https://dev.twitch.tv/docs/embed/video-and-clips/
-		urlParams.set('parent', hostname);
-		urlParams.set('allowfullscreen', '');
-		urlParams.set('autoplay', 'true');
-	} else {
-		urlParams.set('autoplay', '1');
-		urlParams.set('auto_play', '1');
-	}
-	urlObj.search = urlParams.toString();
-
-	return urlObj.toString();
-}
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
deleted file mode 100644
index a8a330eb6d..0000000000
--- a/packages/frontend/src/scripts/please-login.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent } from 'vue';
-import { $i } from '@/account.js';
-import { instance } from '@/instance.js';
-import { i18n } from '@/i18n.js';
-import { popup } from '@/os.js';
-
-export type OpenOnRemoteOptions = {
-	/**
-	 * 外部のMisskey Webで特定のパスを開く
-	 */
-	type: 'web';
-
-	/**
-	 * 内部パス（例: `/settings`）
-	 */
-	path: string;
-} | {
-	/**
-	 * 外部のMisskey Webで照会する
-	 */
-	type: 'lookup';
-
-	/**
-	 * 照会したいエンティティのURL
-	 *
-	 * （例: `https://misskey.example.com/notes/abcdexxxxyz`）
-	 */
-	url: string;
-} | {
-	/**
-	 * 外部のMisskeyでノートする
-	 */
-	type: 'share';
-
-	/**
-	 * `/share` ページに渡すクエリストリング
-	 *
-	 * @see https://go.misskey-hub.net/spec/share/
-	 */
-	params: Record<string, string>;
-};
-
-export function pleaseLogin(opts: {
-	path?: string;
-	message?: string;
-	openOnRemote?: OpenOnRemoteOptions;
-} = {}) {
-	if ($i) return;
-
-	let _openOnRemote: OpenOnRemoteOptions | undefined = undefined;
-
-	// 連合できる場合と、（連合ができなくても）共有する場合は外部連携オプションを設定
-	if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) {
-		_openOnRemote = opts.openOnRemote;
-	}
-
-	const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
-		autoSet: true,
-		message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
-		openOnRemote: _openOnRemote,
-	}, {
-		cancelled: () => {
-			if (opts.path) {
-				window.location.href = opts.path;
-			}
-		},
-		closed: () => dispose(),
-	});
-
-	throw new Error('signin required');
-}
diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts
deleted file mode 100644
index 5b141222e8..0000000000
--- a/packages/frontend/src/scripts/popout.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { appendQuery } from '@@/js/url.js';
-import * as config from '@@/js/config.js';
-
-export function popout(path: string, w?: HTMLElement) {
-	let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
-	url = appendQuery(url, 'zen');
-	if (w) {
-		const position = w.getBoundingClientRect();
-		const width = parseInt(getComputedStyle(w, '').width, 10);
-		const height = parseInt(getComputedStyle(w, '').height, 10);
-		const x = window.screenX + position.left;
-		const y = window.screenY + position.top;
-		window.open(url, url,
-			`width=${width}, height=${height}, top=${y}, left=${x}`);
-	} else {
-		const width = 400;
-		const height = 500;
-		const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
-		const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
-		window.open(url, url,
-			`width=${width}, height=${height}, top=${x}, left=${y}`);
-	}
-}
diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts
deleted file mode 100644
index 3dad41a8b3..0000000000
--- a/packages/frontend/src/scripts/popup-position.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function calcPopupPosition(el: HTMLElement, props: {
-	anchorElement?: HTMLElement | null;
-	innerMargin: number;
-	direction: 'top' | 'bottom' | 'left' | 'right';
-	align: 'top' | 'bottom' | 'left' | 'right' | 'center';
-	alignOffset?: number;
-	x?: number;
-	y?: number;
-}): { top: number; left: number; transformOrigin: string; } {
-	const contentWidth = el.offsetWidth;
-	const contentHeight = el.offsetHeight;
-
-	let rect: DOMRect;
-
-	if (props.anchorElement) {
-		rect = props.anchorElement.getBoundingClientRect();
-	}
-
-	const calcPosWhenTop = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
-		} else {
-			left = props.x;
-			top = (props.y - contentHeight) - props.innerMargin;
-		}
-
-		left -= (el.offsetWidth / 2);
-
-		if (left + contentWidth - window.scrollX > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.scrollX - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenBottom = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
-		} else {
-			left = props.x;
-			top = (props.y) + props.innerMargin;
-		}
-
-		left -= (el.offsetWidth / 2);
-
-		if (left + contentWidth - window.scrollX > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.scrollX - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenLeft = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
-			top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
-		} else {
-			left = (props.x - contentWidth) - props.innerMargin;
-			top = props.y;
-		}
-
-		top -= (el.offsetHeight / 2);
-
-		if (top + contentHeight - window.scrollY > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.scrollY - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calcPosWhenRight = () => {
-		let left: number;
-		let top: number;
-
-		if (props.anchorElement) {
-			left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
-
-			if (props.align === 'top') {
-				top = rect.top + window.scrollY;
-				if (props.alignOffset != null) top += props.alignOffset;
-			} else if (props.align === 'bottom') {
-				// TODO
-			} else { // center
-				top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
-				top -= (el.offsetHeight / 2);
-			}
-		} else {
-			left = props.x + props.innerMargin;
-			top = props.y;
-			top -= (el.offsetHeight / 2);
-		}
-
-		if (top + contentHeight - window.scrollY > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.scrollY - 1;
-		}
-
-		return [left, top];
-	};
-
-	const calc = (): {
-		left: number;
-		top: number;
-		transformOrigin: string;
-	} => {
-		switch (props.direction) {
-			case 'top': {
-				const [left, top] = calcPosWhenTop();
-
-				// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
-				if (top - window.scrollY < 0) {
-					const [left, top] = calcPosWhenBottom();
-					return { left, top, transformOrigin: 'center top' };
-				}
-
-				return { left, top, transformOrigin: 'center bottom' };
-			}
-
-			case 'bottom': {
-				const [left, top] = calcPosWhenBottom();
-				// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
-				return { left, top, transformOrigin: 'center top' };
-			}
-
-			case 'left': {
-				const [left, top] = calcPosWhenLeft();
-
-				// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
-				if (left - window.scrollX < 0) {
-					const [left, top] = calcPosWhenRight();
-					return { left, top, transformOrigin: 'left center' };
-				}
-
-				return { left, top, transformOrigin: 'right center' };
-			}
-
-			case 'right': {
-				const [left, top] = calcPosWhenRight();
-				// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
-				return { left, top, transformOrigin: 'left center' };
-			}
-		}
-	};
-
-	return calc();
-}
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
deleted file mode 100644
index 11b6f52ddd..0000000000
--- a/packages/frontend/src/scripts/post-message.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const postMessageEventTypes = [
-	'misskey:shareForm:shareCompleted',
-] as const;
-
-export type PostMessageEventType = typeof postMessageEventTypes[number];
-
-export type MiPostMessageEvent = {
-	type: PostMessageEventType;
-	payload?: any;
-};
-
-/**
- * 親フレームにイベントを送信
- */
-export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
-	window.parent.postMessage({
-		type,
-		payload,
-	}, '*');
-}
diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts
deleted file mode 100644
index 81f6c02dcf..0000000000
--- a/packages/frontend/src/scripts/reaction-picker.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { defineAsyncComponent, ref } from 'vue';
-import type { Ref } from 'vue';
-import { popup } from '@/os.js';
-import { store } from '@/store.js';
-
-class ReactionPicker {
-	private src: Ref<HTMLElement | null> = ref(null);
-	private manualShowing = ref(false);
-	private targetNote: Ref<Misskey.entities.Note | null> = ref(null);
-	private onChosen?: (reaction: string) => void;
-	private onClosed?: () => void;
-
-	constructor() {
-		// nop
-	}
-
-	public async init() {
-		const reactionsRef = store.reactiveState.reactions;
-		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
-			src: this.src,
-			pinnedEmojis: reactionsRef,
-			asReactionPicker: true,
-			targetNote: this.targetNote,
-			manualShowing: this.manualShowing,
-		}, {
-			done: reaction => {
-				if (this.onChosen) this.onChosen(reaction);
-			},
-			close: () => {
-				this.manualShowing.value = false;
-			},
-			closed: () => {
-				this.src.value = null;
-				if (this.onClosed) this.onClosed();
-			},
-		});
-	}
-
-	public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
-		this.src.value = src;
-		this.targetNote.value = targetNote;
-		this.manualShowing.value = true;
-		this.onChosen = onChosen;
-		this.onClosed = onClosed;
-	}
-}
-
-export const reactionPicker = new ReactionPicker();
diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/scripts/reload-ask.ts
deleted file mode 100644
index 733d91b85a..0000000000
--- a/packages/frontend/src/scripts/reload-ask.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { i18n } from '@/i18n.js';
-import * as os from '@/os.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
-
-let isReloadConfirming = false;
-
-export async function reloadAsk(opts: {
-	unison?: boolean;
-	reason?: string;
-}) {
-	if (isReloadConfirming) {
-		return;
-	}
-
-	isReloadConfirming = true;
-
-	const { canceled } = await os.confirm(opts.reason == null ? {
-		type: 'info',
-		text: i18n.ts.reloadConfirm,
-	} : {
-		type: 'info',
-		title: i18n.ts.reloadConfirm,
-		text: opts.reason,
-	}).finally(() => {
-		isReloadConfirming = false;
-	});
-
-	if (canceled) return;
-
-	if (opts.unison) {
-		unisonReload();
-	} else {
-		location.reload();
-	}
-}
diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/scripts/search-emoji.ts
deleted file mode 100644
index 371f69b9a7..0000000000
--- a/packages/frontend/src/scripts/search-emoji.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export type EmojiDef = {
-	emoji: string;
-	name: string;
-	url: string;
-	aliasOf?: string;
-} | {
-	emoji: string;
-	name: string;
-	aliasOf?: string;
-	isCustomEmoji?: true;
-};
-type EmojiScore = { emoji: EmojiDef, score: number };
-
-export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
-	if (!query) {
-		return [];
-	}
-
-	const matched = new Map<string, EmojiScore>();
-	// 完全一致（エイリアスなし）
-	emojiDb.some(x => {
-		if (x.name === query && !x.aliasOf) {
-			matched.set(x.name, { emoji: x, score: query.length + 3 });
-		}
-		return matched.size === max;
-	});
-
-	// 完全一致（エイリアス込み）
-	if (matched.size < max) {
-		emojiDb.some(x => {
-			if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
-				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
-			}
-			return matched.size === max;
-		});
-	}
-
-	// 前方一致（エイリアスなし）
-	if (matched.size < max) {
-		emojiDb.some(x => {
-			if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
-				matched.set(x.name, { emoji: x, score: query.length + 1 });
-			}
-			return matched.size === max;
-		});
-	}
-
-	// 前方一致（エイリアス込み）
-	if (matched.size < max) {
-		emojiDb.some(x => {
-			if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
-				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
-			}
-			return matched.size === max;
-		});
-	}
-
-	// 部分一致（エイリアス込み）
-	if (matched.size < max) {
-		emojiDb.some(x => {
-			if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
-				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
-			}
-			return matched.size === max;
-		});
-	}
-
-	// 簡易あいまい検索（3文字以上）
-	if (matched.size < max && query.length > 3) {
-		const queryChars = [...query];
-		const hitEmojis = new Map<string, EmojiScore>();
-
-		for (const x of emojiDb) {
-			// 文字列の位置を進めながら、クエリの文字を順番に探す
-
-			let pos = 0;
-			let hit = 0;
-			for (const c of queryChars) {
-				pos = x.name.indexOf(c, pos);
-				if (pos <= -1) break;
-				hit++;
-			}
-
-			// 半分以上の文字が含まれていればヒットとする
-			if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
-				hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
-			}
-		}
-
-		// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく（6件＝オートコンプリートのポップアップのサイズ分）
-		[...hitEmojis.values()]
-			.sort((x, y) => y.score - x.score)
-			.slice(0, 6)
-			.forEach(it => matched.set(it.emoji.name, it));
-	}
-
-	return [...matched.values()]
-		.sort((x, y) => y.score - x.score)
-		.slice(0, max)
-		.map(it => it.emoji);
-}
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
deleted file mode 100644
index 42b34f54f5..0000000000
--- a/packages/frontend/src/scripts/select-file.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useStream } from '@/stream.js';
-import { i18n } from '@/i18n.js';
-import { uploadFile } from '@/scripts/upload.js';
-import { prefer } from '@/preferences.js';
-
-export function chooseFileFromPc(
-	multiple: boolean,
-	options?: {
-		uploadFolder?: string | null;
-		keepOriginal?: boolean;
-		nameConverter?: (file: File) => string | undefined;
-	},
-): Promise<Misskey.entities.DriveFile[]> {
-	const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
-	const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading;
-	const nameConverter = options?.nameConverter ?? (() => undefined);
-
-	return new Promise((res, rej) => {
-		const input = document.createElement('input');
-		input.type = 'file';
-		input.multiple = multiple;
-		input.onchange = () => {
-			if (!input.files) return res([]);
-			const promises = Array.from(
-				input.files,
-				file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
-			);
-
-			Promise.all(promises).then(driveFiles => {
-				res(driveFiles);
-			}).catch(err => {
-				// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
-			});
-
-			// 一応廃棄
-			(window as any).__misskey_input_ref__ = null;
-		};
-
-		// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
-		// iOS Safari で正常に動かす為のおまじない
-		(window as any).__misskey_input_ref__ = input;
-
-		input.click();
-	});
-}
-
-export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
-	return new Promise((res, rej) => {
-		os.selectDriveFile(multiple).then(files => {
-			res(files);
-		});
-	});
-}
-
-export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
-	return new Promise((res, rej) => {
-		os.inputText({
-			title: i18n.ts.uploadFromUrl,
-			type: 'url',
-			placeholder: i18n.ts.uploadFromUrlDescription,
-		}).then(({ canceled, result: url }) => {
-			if (canceled) return;
-
-			const marker = Math.random().toString(); // TODO: UUIDとか使う
-
-			const connection = useStream().useChannel('main');
-			connection.on('urlUploadFinished', urlResponse => {
-				if (urlResponse.marker === marker) {
-					res(urlResponse.file);
-					connection.dispose();
-				}
-			});
-
-			misskeyApi('drive/files/upload-from-url', {
-				url: url,
-				folderId: prefer.s.uploadFolder,
-				marker,
-			});
-
-			os.alert({
-				title: i18n.ts.uploadFromUrlRequested,
-				text: i18n.ts.uploadFromUrlMayTakeTime,
-			});
-		});
-	});
-}
-
-function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
-	return new Promise((res, rej) => {
-		const keepOriginal = ref(prefer.s.keepOriginalUploading);
-
-		os.popupMenu([label ? {
-			text: label,
-			type: 'label',
-		} : undefined, {
-			type: 'switch',
-			text: i18n.ts.keepOriginalUploading,
-			ref: keepOriginal,
-		}, {
-			text: i18n.ts.upload,
-			icon: 'ti ti-upload',
-			action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
-		}, {
-			text: i18n.ts.fromDrive,
-			icon: 'ti ti-cloud',
-			action: () => chooseFileFromDrive(multiple).then(files => res(files)),
-		}, {
-			text: i18n.ts.fromUrl,
-			icon: 'ti ti-link',
-			action: () => chooseFileFromUrl().then(file => res([file])),
-		}], src);
-	});
-}
-
-export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
-	return select(src, label, false).then(files => files[0]);
-}
-
-export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
-	return select(src, label, true);
-}
diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/scripts/show-moved-dialog.ts
deleted file mode 100644
index 35b3ef79d8..0000000000
--- a/packages/frontend/src/scripts/show-moved-dialog.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { $i } from '@/account.js';
-import { i18n } from '@/i18n.js';
-
-export function showMovedDialog() {
-	if (!$i) return;
-	if (!$i.movedTo) return;
-
-	os.alert({
-		type: 'error',
-		title: i18n.ts.accountMovedShort,
-		text: i18n.ts.operationForbidden,
-	});
-
-	throw new Error('account moved');
-}
diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts
deleted file mode 100644
index 8b89dbb936..0000000000
--- a/packages/frontend/src/scripts/show-suspended-dialog.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { i18n } from '@/i18n.js';
-
-export function showSuspendedDialog() {
-	return os.alert({
-		type: 'error',
-		title: i18n.ts.yourAccountSuspendedTitle,
-		text: i18n.ts.yourAccountSuspendedDescription,
-	});
-}
diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts
deleted file mode 100644
index 1f6ef1928c..0000000000
--- a/packages/frontend/src/scripts/shuffle.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * 配列をシャッフル (破壊的)
- */
-export function shuffle<T extends unknown[]>(array: T): T {
-	let currentIndex = array.length;
-	let randomIndex: number;
-
-	// While there remain elements to shuffle.
-	while (currentIndex !== 0) {
-		// Pick a remaining element.
-		randomIndex = Math.floor(Math.random() * currentIndex);
-		currentIndex--;
-
-		// And swap it with the current element.
-		[array[currentIndex], array[randomIndex]] = [
-			array[randomIndex], array[currentIndex]];
-	}
-
-	return array;
-}
diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/scripts/snowfall-effect.ts
deleted file mode 100644
index d88bdb6660..0000000000
--- a/packages/frontend/src/scripts/snowfall-effect.ts
+++ /dev/null
@@ -1,490 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export class SnowfallEffect {
-	private VERTEX_SOURCE = `#version 300 es
-		in vec4 a_position;
-		in vec4 a_color;
-		in vec3 a_rotation;
-		in vec3 a_speed;
-		in float a_size;
-		out vec4 v_color;
-		out float v_rotation;
-		uniform float u_time;
-		uniform mat4 u_projection;
-		uniform vec3 u_worldSize;
-		uniform float u_gravity;
-		uniform float u_wind;
-		uniform float u_spin_factor;
-		uniform float u_turbulence;
-
-		void main() {
-			v_color = a_color;
-			v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y;
-
-			vec3 pos = a_position.xyz;
-
-			pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x;
-			pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y;
-
-			pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z;
-			pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z;
-
-			gl_Position = u_projection * vec4(pos.xyz, a_position.w);
-			gl_PointSize = (a_size / gl_Position.w) * 100.0;
-		}
-	`;
-
-	private FRAGMENT_SOURCE = `#version 300 es
-		precision highp float;
-
-		in vec4 v_color;
-		in float v_rotation;
-		uniform sampler2D u_texture;
-		out vec4 out_color;
-
-		void main() {
-			vec2 rotated = vec2(
-				cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5,
-				cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5
-			);
-
-			vec4 snowflake = texture(u_texture, rotated);
-
-			out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a);
-		}
-	`;
-
-	private gl: WebGLRenderingContext;
-	private program: WebGLProgram;
-	private canvas: HTMLCanvasElement;
-	private buffers: Record<string, {
-		size: number;
-		value: number[] | Float32Array;
-		location: number;
-		ref: WebGLBuffer;
-	}>;
-	private uniforms: Record<string, {
-		type: string;
-		value: number[] | Float32Array;
-		location: WebGLUniformLocation;
-	}>;
-	private texture: WebGLTexture;
-	private camera: {
-		fov: number;
-		near: number;
-		far: number;
-		aspect: number;
-		z: number;
-	};
-	private wind: {
-		current: number;
-		force: number;
-		target: number;
-		min: number;
-		max: number;
-		easing: number;
-	};
-	private time: {
-		start: number;
-		previous: number;
-	} = {
-			start: 0,
-			previous: 0,
-		};
-	private raf = 0;
-
-	private density: number = 1 / 90;
-	private depth = 100;
-	private count = 1000;
-	private gravity = 100;
-	private speed: number = 1 / 10000;
-	private color: number[] = [1, 1, 1];
-	private opacity = 1;
-	private size = 4;
-	private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg==';
-	private mode = 'snow';
-
-	private INITIAL_BUFFERS = () => ({
-		position: { size: 3, value: [] },
-		color: { size: 4, value: [] },
-		size: { size: 1, value: [] },
-		rotation: { size: 3, value: [] },
-		speed: { size: 3, value: [] },
-	});
-
-	private INITIAL_UNIFORMS = () => ({
-		time: { type: 'float', value: 0 },
-		worldSize: { type: 'vec3', value: [0, 0, 0] },
-		gravity: { type: 'float', value: this.gravity },
-		wind: { type: 'float', value: 0 },
-		spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 },
-		turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 },
-		projection: {
-			type: 'mat4',
-			value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
-		},
-	});
-
-	private UNIFORM_SETTERS = {
-		int: 'uniform1i',
-		float: 'uniform1f',
-		vec2: 'uniform2fv',
-		vec3: 'uniform3fv',
-		vec4: 'uniform4fv',
-		mat2: 'uniformMatrix2fv',
-		mat3: 'uniformMatrix3fv',
-		mat4: 'uniformMatrix4fv',
-	};
-
-	private CAMERA = {
-		fov: 60,
-		near: 5,
-		far: 10000,
-		aspect: 1,
-		z: 100,
-	};
-
-	private WIND = {
-		current: 0,
-		force: 0.01,
-		target: 0.01,
-		min: 0,
-		max: 0.125,
-		easing: 0.0005,
-	};
-	/**
-	 * @throws {Error} - Thrown when it fails to get WebGL context for the canvas 
-	 */
-	constructor(options: {
-		sakura?: boolean;
-	}) {
-		if (options.sakura) {
-			this.mode = 'sakura';
-			this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC';
-			this.size = 10;
-			this.density = 1 / 280;
-		}
-
-		const canvas = this.initCanvas();
-		const gl = canvas.getContext('webgl2', { antialias: true });
-		if (gl == null) throw new Error('Failed to get WebGL context');
-
-		document.body.append(canvas);
-
-		this.canvas = canvas;
-		this.gl = gl;
-		this.program = this.initProgram();
-		this.buffers = this.initBuffers();
-		this.uniforms = this.initUniforms();
-		this.texture = this.initTexture();
-		this.camera = this.initCamera();
-		this.wind = this.initWind();
-
-		this.resize = this.resize.bind(this);
-		this.update = this.update.bind(this);
-
-		window.addEventListener('resize', () => this.resize());
-	}
-
-	private initCanvas(): HTMLCanvasElement {
-		const canvas = document.createElement('canvas');
-
-		Object.assign(canvas.style, {
-			position: 'fixed',
-			top: 0,
-			left: 0,
-			width: '100vw',
-			height: '100vh',
-			background: 'transparent',
-			'pointer-events': 'none',
-			'z-index': 2147483647,
-		});
-
-		return canvas;
-	}
-
-	private initCamera() {
-		return { ...this.CAMERA };
-	}
-
-	private initWind() {
-		return { ...this.WIND };
-	}
-
-	private initShader(type, source): WebGLShader {
-		const { gl } = this;
-		const shader = gl.createShader(type);
-		if (shader == null) throw new Error('Failed to create shader');
-
-		gl.shaderSource(shader, source);
-		gl.compileShader(shader);
-
-		return shader;
-	}
-
-	private initProgram(): WebGLProgram {
-		const { gl } = this;
-		const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE);
-		const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE);
-		const program = gl.createProgram();
-		if (program == null) throw new Error('Failed to create program');
-
-		gl.attachShader(program, vertex);
-		gl.attachShader(program, fragment);
-		gl.linkProgram(program);
-		gl.useProgram(program);
-
-		return program;
-	}
-
-	private initBuffers(): SnowfallEffect['buffers'] {
-		const { gl, program } = this;
-		const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers'];
-
-		for (const [name, buffer] of Object.entries(buffers)) {
-			buffer.location = gl.getAttribLocation(program, `a_${name}`);
-			buffer.ref = gl.createBuffer()!;
-
-			gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
-			gl.enableVertexAttribArray(buffer.location);
-			gl.vertexAttribPointer(
-				buffer.location,
-				buffer.size,
-				gl.FLOAT,
-				false,
-				0,
-				0,
-			);
-		}
-
-		return buffers;
-	}
-
-	private updateBuffers() {
-		const { buffers } = this;
-
-		for (const name of Object.keys(buffers)) {
-			this.setBuffer(name);
-		}
-	}
-
-	private setBuffer(name: string, value?) {
-		const { gl, buffers } = this;
-		const buffer = buffers[name];
-
-		buffer.value = new Float32Array(value ?? buffer.value);
-
-		gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
-		gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW);
-	}
-
-	private initUniforms(): SnowfallEffect['uniforms'] {
-		const { gl, program } = this;
-		const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms'];
-
-		for (const [name, uniform] of Object.entries(uniforms)) {
-			uniform.location = gl.getUniformLocation(program, `u_${name}`)!;
-		}
-
-		return uniforms;
-	}
-
-	private updateUniforms() {
-		const { uniforms } = this;
-
-		for (const name of Object.keys(uniforms)) {
-			this.setUniform(name);
-		}
-	}
-
-	private setUniform(name: string, value?) {
-		const { gl, uniforms } = this;
-		const uniform = uniforms[name];
-		const setter = this.UNIFORM_SETTERS[uniform.type];
-		const isMatrix = /^mat[2-4]$/i.test(uniform.type);
-
-		uniform.value = value ?? uniform.value;
-
-		if (isMatrix) {
-			gl[setter](uniform.location, false, uniform.value);
-		} else {
-			gl[setter](uniform.location, uniform.value);
-		}
-	}
-
-	private initTexture() {
-		const { gl } = this;
-		const texture = gl.createTexture();
-		if (texture == null) throw new Error('Failed to create texture');
-		const image = new Image();
-
-		gl.bindTexture(gl.TEXTURE_2D, texture);
-		gl.texImage2D(
-			gl.TEXTURE_2D,
-			0,
-			gl.RGBA,
-			1,
-			1,
-			0,
-			gl.RGBA,
-			gl.UNSIGNED_BYTE,
-			new Uint8Array([0, 0, 0, 0]),
-		);
-
-		image.onload = () => {
-			gl.bindTexture(gl.TEXTURE_2D, texture);
-			gl.texImage2D(
-				gl.TEXTURE_2D,
-				0,
-				gl.RGBA,
-				gl.RGBA,
-				gl.UNSIGNED_BYTE,
-				image,
-			);
-			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
-			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
-			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-		};
-
-		image.src = this.snowflake;
-
-		return texture;
-	}
-
-	private initSnowflakes(vw: number, vh: number, dpi: number) {
-		const position: number[] = [];
-		const color: number[] = [];
-		const size: number[] = [];
-		const rotation: number[] = [];
-		const speed: number[] = [];
-
-		const height = 1 / this.density;
-		const width = (vw / vh) * height;
-		const depth = this.depth;
-		const count = this.count;
-		const length = (vw / vh) * count;
-
-		for (let i = 0; i < length; ++i) {
-			position.push(
-				-width + Math.random() * width * 2,
-				-height + Math.random() * height * 2,
-				Math.random() * depth * 2,
-			);
-
-			speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10);
-
-			rotation.push(
-				Math.random() * 2 * Math.PI,
-				Math.random() * 20,
-				Math.random() * 10,
-			);
-
-			color.push(...this.color, 0.1 + Math.random() * this.opacity);
-			//size.push((this.size * Math.random() * this.size * vh * dpi) / 1000);
-			size.push((this.size * vh * dpi) / 1000);
-		}
-
-		this.setUniform('worldSize', [width, height, depth]);
-
-		this.setBuffer('position', position);
-		this.setBuffer('color', color);
-		this.setBuffer('rotation', rotation);
-		this.setBuffer('size', size);
-		this.setBuffer('speed', speed);
-	}
-
-	private setProjection(aspect: number) {
-		const { camera } = this;
-
-		camera.aspect = aspect;
-
-		const fovRad = (camera.fov * Math.PI) / 180;
-		const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad);
-		const rangeInv = 1.0 / (camera.near - camera.far);
-
-		const m0 = f / camera.aspect;
-		const m5 = f;
-		const m10 = (camera.near + camera.far) * rangeInv;
-		const m11 = -1;
-		const m14 = camera.near * camera.far * rangeInv * 2 + camera.z;
-		const m15 = camera.z;
-
-		return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15];
-	}
-
-	public render() {
-		const { gl } = this;
-
-		gl.enable(gl.BLEND);
-		gl.enable(gl.CULL_FACE);
-		gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
-		gl.disable(gl.DEPTH_TEST);
-
-		this.updateBuffers();
-		this.updateUniforms();
-		this.resize(true);
-
-		this.time = {
-			start: window.performance.now(),
-			previous: window.performance.now(),
-		};
-
-		if (this.raf) window.cancelAnimationFrame(this.raf);
-		this.raf = window.requestAnimationFrame(this.update);
-
-		return this;
-	}
-
-	private resize(updateSnowflakes = false) {
-		const { canvas, gl } = this;
-		const vw = canvas.offsetWidth;
-		const vh = canvas.offsetHeight;
-		const aspect = vw / vh;
-		const dpi = window.devicePixelRatio;
-
-		canvas.width = vw * dpi;
-		canvas.height = vh * dpi;
-
-		gl.viewport(0, 0, vw * dpi, vh * dpi);
-		gl.clearColor(0, 0, 0, 0);
-
-		if (updateSnowflakes === true) {
-			this.initSnowflakes(vw, vh, dpi);
-		}
-
-		this.setUniform('projection', this.setProjection(aspect));
-	}
-
-	private update(timestamp: number) {
-		const { gl, buffers, wind } = this;
-		const elapsed = (timestamp - this.time.start) * this.speed;
-		const delta = timestamp - this.time.previous;
-
-		gl.clear(gl.COLOR_BUFFER_BIT);
-		gl.drawArrays(
-			gl.POINTS,
-			0,
-			buffers.position.value.length / buffers.position.size,
-		);
-
-		if (Math.random() > 0.995) {
-			wind.target =
-				(wind.min + Math.random() * (wind.max - wind.min)) *
-				(Math.random() > 0.5 ? -1 : 1);
-		}
-
-		wind.force += (wind.target - wind.force) * wind.easing;
-		wind.current += wind.force * (delta * 0.2);
-
-		this.setUniform('wind', wind.current);
-		this.setUniform('time', elapsed);
-
-		this.time.previous = timestamp;
-
-		this.raf = window.requestAnimationFrame(this.update);
-	}
-}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
deleted file mode 100644
index 436c2b75f0..0000000000
--- a/packages/frontend/src/scripts/sound.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { SoundStore } from '@/preferences/def.js';
-import { prefer } from '@/preferences.js';
-import { PREF_DEF } from '@/preferences/def.js';
-
-let ctx: AudioContext;
-const cache = new Map<string, AudioBuffer>();
-let canPlay = true;
-
-export const soundsTypes = [
-	// 音声なし
-	null,
-
-	// ドライブの音声
-	'_driveFile_',
-
-	// プリインストール
-	'syuilo/n-aec',
-	'syuilo/n-aec-4va',
-	'syuilo/n-aec-4vb',
-	'syuilo/n-aec-8va',
-	'syuilo/n-aec-8vb',
-	'syuilo/n-cea',
-	'syuilo/n-cea-4va',
-	'syuilo/n-cea-4vb',
-	'syuilo/n-cea-8va',
-	'syuilo/n-cea-8vb',
-	'syuilo/n-eca',
-	'syuilo/n-eca-4va',
-	'syuilo/n-eca-4vb',
-	'syuilo/n-eca-8va',
-	'syuilo/n-eca-8vb',
-	'syuilo/n-ea',
-	'syuilo/n-ea-4va',
-	'syuilo/n-ea-4vb',
-	'syuilo/n-ea-8va',
-	'syuilo/n-ea-8vb',
-	'syuilo/n-ea-harmony',
-	'syuilo/up',
-	'syuilo/down',
-	'syuilo/pope1',
-	'syuilo/pope2',
-	'syuilo/waon',
-	'syuilo/popo',
-	'syuilo/triple',
-	'syuilo/bubble1',
-	'syuilo/bubble2',
-	'syuilo/poi1',
-	'syuilo/poi2',
-	'syuilo/pirori',
-	'syuilo/pirori-wet',
-	'syuilo/pirori-square-wet',
-	'syuilo/square-pico',
-	'syuilo/reverved',
-	'syuilo/ryukyu',
-	'syuilo/kick',
-	'syuilo/snare',
-	'syuilo/queue-jammed',
-	'aisha/1',
-	'aisha/2',
-	'aisha/3',
-	'noizenecio/kick_gaba1',
-	'noizenecio/kick_gaba2',
-	'noizenecio/kick_gaba3',
-	'noizenecio/kick_gaba4',
-	'noizenecio/kick_gaba5',
-	'noizenecio/kick_gaba6',
-	'noizenecio/kick_gaba7',
-] as const;
-
-export const operationTypes = [
-	'noteMy',
-	'note',
-	'notification',
-	'reaction',
-] as const;
-
-/** サウンドの種類 */
-export type SoundType = typeof soundsTypes[number];
-
-/** スプライトの種類 */
-export type OperationType = typeof operationTypes[number];
-
-/**
- * 音声を読み込む
- * @param url url
- * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
- */
-export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	if (ctx == null) {
-		ctx = new AudioContext();
-
-		window.addEventListener('beforeunload', () => {
-			ctx.close();
-		});
-	}
-	if (options?.useCache ?? true) {
-		if (cache.has(url)) {
-			return cache.get(url) as AudioBuffer;
-		}
-	}
-
-	let response: Response;
-
-	try {
-		response = await fetch(url);
-	} catch (err) {
-		return;
-	}
-
-	const arrayBuffer = await response.arrayBuffer();
-	const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
-
-	if (options?.useCache ?? true) {
-		cache.set(url, audioBuffer);
-	}
-
-	return audioBuffer;
-}
-
-/**
- * 既定のスプライトを再生する
- * @param type スプライトの種類を指定
- */
-export function playMisskeySfx(operationType: OperationType) {
-	const sound = prefer.s[`sound.on.${operationType}`];
-	playMisskeySfxFile(sound).then((succeed) => {
-		if (!succeed && sound.type === '_driveFile_') {
-			// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
-			const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
-			if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
-			playMisskeySfxFileInternal({
-				type: soundName,
-				volume: sound.volume,
-			});
-		}
-	});
-}
-
-/**
- * サウンド設定形式で指定された音声を再生する
- * @param soundStore サウンド設定
- */
-export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolean> {
-	// 連続して再生しない
-	if (!canPlay) return false;
-	// ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
-	if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
-	// サウンドがない場合は再生しない
-	if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
-
-	canPlay = false;
-	return await playMisskeySfxFileInternal(soundStore).finally(() => {
-		// ごく短時間に音が重複しないように
-		setTimeout(() => {
-			canPlay = true;
-		}, 25);
-	});
-}
-
-async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boolean> {
-	if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
-		return false;
-	}
-	const masterVolume = prefer.s['sound.masterVolume'];
-	if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
-		return true; // ミュート時は成功として扱う
-	}
-	const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
-	const buffer = await loadAudio(url).catch(() => {
-		return undefined;
-	});
-	if (!buffer) return false;
-	const volume = soundStore.volume * masterVolume;
-	createSourceNode(buffer, { volume }).soundSource.start();
-	return true;
-}
-
-export async function playUrl(url: string, opts: {
-	volume?: number;
-	pan?: number;
-	playbackRate?: number;
-}) {
-	if (opts.volume === 0) {
-		return;
-	}
-	const buffer = await loadAudio(url);
-	if (!buffer) return;
-	createSourceNode(buffer, opts).soundSource.start();
-}
-
-export function createSourceNode(buffer: AudioBuffer, opts: {
-	volume?: number;
-	pan?: number;
-	playbackRate?: number;
-}): {
-		soundSource: AudioBufferSourceNode;
-		panNode: StereoPannerNode;
-		gainNode: GainNode;
-	} {
-	const panNode = ctx.createStereoPanner();
-	panNode.pan.value = opts.pan ?? 0;
-
-	const gainNode = ctx.createGain();
-
-	gainNode.gain.value = opts.volume ?? 1;
-
-	const soundSource = ctx.createBufferSource();
-	soundSource.buffer = buffer;
-	soundSource.playbackRate.value = opts.playbackRate ?? 1;
-	soundSource
-		.connect(panNode)
-		.connect(gainNode)
-		.connect(ctx.destination);
-
-	return { soundSource, panNode, gainNode };
-}
-
-/**
- * 音声の長さをミリ秒で取得する
- * @param file ファイルのURL（ドライブIDではない）
- */
-export async function getSoundDuration(file: string): Promise<number> {
-	const audioEl = document.createElement('audio');
-	audioEl.src = file;
-	return new Promise((resolve) => {
-		const si = setInterval(() => {
-			if (audioEl.readyState > 0) {
-				resolve(audioEl.duration * 1000);
-				clearInterval(si);
-				audioEl.remove();
-			}
-		}, 100);
-	});
-}
-
-/**
- * ミュートすべきかどうかを判断する
- */
-export function isMute(): boolean {
-	if (prefer.s['sound.notUseSound']) {
-		// サウンドを出力しない
-		return true;
-	}
-
-	// noinspection RedundantIfStatementJS
-	if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
-		// ブラウザがアクティブな時のみサウンドを出力する
-		return true;
-	}
-
-	return false;
-}
diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts
deleted file mode 100644
index 50f1e6ecc8..0000000000
--- a/packages/frontend/src/scripts/sticky-sidebar.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export class StickySidebar {
-	private lastScrollTop = 0;
-	private container: HTMLElement;
-	private el: HTMLElement;
-	private spacer: HTMLElement;
-	private marginTop: number;
-	private isTop = false;
-	private isBottom = false;
-	private offsetTop: number;
-	private globalHeaderHeight = 59;
-
-	constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
-		this.container = container;
-		this.el = this.container.children[0] as HTMLElement;
-		this.el.style.position = 'sticky';
-		this.spacer = document.createElement('div');
-		this.container.prepend(this.spacer);
-		this.marginTop = marginTop;
-		this.offsetTop = this.container.getBoundingClientRect().top;
-		this.globalHeaderHeight = globalHeaderHeight;
-	}
-
-	public calc(scrollTop: number) {
-		if (scrollTop > this.lastScrollTop) { // downscroll
-			const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
-			this.el.style.bottom = null;
-			this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
-
-			this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
-
-			if (this.isTop) {
-				this.isTop = false;
-				this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
-			}
-		} else { // upscroll
-			const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
-			this.el.style.top = null;
-			this.el.style.bottom = `${-overflow}px`;
-
-			this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
-
-			if (this.isBottom) {
-				this.isBottom = false;
-				this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
-			}
-		}
-
-		this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
-	}
-}
diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts
deleted file mode 100644
index 9b1b368de4..0000000000
--- a/packages/frontend/src/scripts/stream-mock.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { EventEmitter } from 'eventemitter3';
-import * as Misskey from 'misskey-js';
-import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
-
-type AnyOf<T extends Record<any, any>> = T[keyof T];
-type OmitFirst<T extends any[]> = T extends [any, ...infer R] ? R : never;
-
-/**
- * Websocket無効化時に使うStreamのモック（なにもしない）
- */
-export class StreamMock extends EventEmitter<StreamEvents> implements IStream {
-	public readonly state = 'initializing';
-
-	constructor(...args: ConstructorParameters<typeof Misskey.Stream>) {
-		super();
-		// do nothing
-	}
-
-	public useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock<Channels[C]> {
-		return new ChannelConnectionMock(this, channel, name);
-	}
-
-	public removeSharedConnection(connection: any): void {
-		// do nothing
-	}
-
-	public removeSharedConnectionPool(pool: any): void {
-		// do nothing
-	}
-
-	public disconnectToChannel(): void {
-		// do nothing
-	}
-
-	public send(typeOrPayload: string): void;
-	public send(typeOrPayload: string, payload: any): void;
-	public send(typeOrPayload: Record<string, any> | any[]): void;
-	public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void {
-		// do nothing
-	}
-
-	public ping(): void {
-		// do nothing
-	}
-
-	public heartbeat(): void {
-		// do nothing
-	}
-
-	public close(): void {
-		// do nothing
-	}
-}
-
-class ChannelConnectionMock<Channel extends AnyOf<Channels> = any> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> {
-	public id = '';
-	public name?: string; // for debug
-	public inCount = 0; // for debug
-	public outCount = 0; // for debug
-	public channel: string;
-
-	constructor(stream: IStream, ...args: OmitFirst<ConstructorParameters<typeof Misskey.ChannelConnection<Channel>>>) {
-		super();
-
-		this.channel = args[0];
-		this.name = args[1];
-	}
-
-	public send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void {
-		// do nothing
-	}
-
-	public dispose(): void {
-		// do nothing
-	}
-}
diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/scripts/test-utils.ts
deleted file mode 100644
index 52bb2d94e0..0000000000
--- a/packages/frontend/src/scripts/test-utils.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export async function tick(): Promise<void> {
-	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
-}
diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts
deleted file mode 100644
index 0206e378bf..0000000000
--- a/packages/frontend/src/scripts/theme-editor.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { v4 as uuid } from 'uuid';
-
-import { themeProps } from './theme.js';
-import type { Theme } from './theme.js';
-
-export type Default = null;
-export type Color = string;
-export type FuncName = 'alpha' | 'darken' | 'lighten';
-export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
-export type RefProp = { type: 'refProp'; key: string; };
-export type RefConst = { type: 'refConst'; key: string; };
-export type Css = { type: 'css'; value: string; };
-
-export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
-
-export type ThemeViewModel = [ string, ThemeValue ][];
-
-export const fromThemeString = (str?: string) : ThemeValue => {
-	if (!str) return null;
-	if (str.startsWith(':')) {
-		const parts = str.slice(1).split('<');
-		const name = parts[0] as FuncName;
-		const arg = parseFloat(parts[1]);
-		const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
-		return { type: 'func', name, arg, value };
-	} else if (str.startsWith('@')) {
-		return {
-			type: 'refProp',
-			key: str.slice(1),
-		};
-	} else if (str.startsWith('$')) {
-		return {
-			type: 'refConst',
-			key: str.slice(1),
-		};
-	} else if (str.startsWith('"')) {
-		return {
-			type: 'css',
-			value: str.substring(1).trim(),
-		};
-	} else {
-		return str;
-	}
-};
-
-export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
-	if (typeof value === 'string') return value;
-	switch (value.type) {
-		case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
-		case 'refProp': return `@${value.key}`;
-		case 'refConst': return `$${value.key}`;
-		case 'css': return `" ${value.value}`;
-	}
-};
-
-export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
-	const props = { } as { [key: string]: string };
-	for (const [key, value] of vm) {
-		if (value === null) continue;
-		props[key] = toThemeString(value);
-	}
-
-	return {
-		id: uuid(),
-		name, desc, author, props, base,
-	};
-};
-
-export const convertToViewModel = (theme: Theme): ThemeViewModel => {
-	const vm: ThemeViewModel = [];
-	// プロパティの登録
-	vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
-
-	// 定数の登録
-	const consts = Object
-		.keys(theme.props)
-		.filter(k => k.startsWith('$'))
-		.map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
-
-	vm.push(...consts);
-	return vm;
-};
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
deleted file mode 100644
index 851ba41e61..0000000000
--- a/packages/frontend/src/scripts/theme.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import tinycolor from 'tinycolor2';
-import lightTheme from '@@/themes/_light.json5';
-import darkTheme from '@@/themes/_dark.json5';
-import JSON5 from 'json5';
-import { deepClone } from './clone.js';
-import type { BundledTheme } from 'shiki/themes';
-import { globalEvents } from '@/events.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { addTheme, getThemes } from '@/theme-store.js';
-
-export type Theme = {
-	id: string;
-	name: string;
-	author: string;
-	desc?: string;
-	base?: 'dark' | 'light';
-	props: Record<string, string>;
-	codeHighlighter?: {
-		base: BundledTheme;
-		overrides?: Record<string, any>;
-	} | {
-		base: '_none_';
-		overrides: Record<string, any>;
-	};
-};
-
-export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
-
-export const getBuiltinThemes = () => Promise.all(
-	[
-		'l-light',
-		'l-coffee',
-		'l-apricot',
-		'l-rainy',
-		'l-botanical',
-		'l-vivid',
-		'l-cherry',
-		'l-sushi',
-		'l-u0',
-
-		'd-dark',
-		'd-persimmon',
-		'd-astro',
-		'd-future',
-		'd-botanical',
-		'd-green-lime',
-		'd-green-orange',
-		'd-cherry',
-		'd-ice',
-		'd-u0',
-	].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
-);
-
-export const getBuiltinThemesRef = () => {
-	const builtinThemes = ref<Theme[]>([]);
-	getBuiltinThemes().then(themes => builtinThemes.value = themes);
-	return builtinThemes;
-};
-
-let timeout: number | null = null;
-
-export function applyTheme(theme: Theme, persist = true) {
-	if (timeout) window.clearTimeout(timeout);
-
-	document.documentElement.classList.add('_themeChanging_');
-
-	timeout = window.setTimeout(() => {
-		document.documentElement.classList.remove('_themeChanging_');
-	}, 1000);
-
-	const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
-
-	document.documentElement.dataset.colorScheme = colorScheme;
-
-	// Deep copy
-	const _theme = deepClone(theme);
-
-	if (_theme.base) {
-		const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
-		if (base) _theme.props = Object.assign({}, base.props, _theme.props);
-	}
-
-	const props = compile(_theme);
-
-	for (const tag of document.head.children) {
-		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
-			tag.setAttribute('content', props['htmlThemeColor']);
-			break;
-		}
-	}
-
-	for (const [k, v] of Object.entries(props)) {
-		document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
-	}
-
-	document.documentElement.style.setProperty('color-scheme', colorScheme);
-
-	if (persist) {
-		miLocalStorage.setItem('theme', JSON.stringify(props));
-		miLocalStorage.setItem('themeId', theme.id);
-		miLocalStorage.setItem('colorScheme', colorScheme);
-	}
-
-	// 色計算など再度行えるようにクライアント全体に通知
-	globalEvents.emit('themeChanged');
-}
-
-function compile(theme: Theme): Record<string, string> {
-	function getColor(val: string): tinycolor.Instance {
-		if (val[0] === '@') { // ref (prop)
-			return getColor(theme.props[val.substring(1)]);
-		} else if (val[0] === '$') { // ref (const)
-			return getColor(theme.props[val]);
-		} else if (val[0] === ':') { // func
-			const parts = val.split('<');
-			const func = parts.shift().substring(1);
-			const arg = parseFloat(parts.shift());
-			const color = getColor(parts.join('<'));
-
-			switch (func) {
-				case 'darken': return color.darken(arg);
-				case 'lighten': return color.lighten(arg);
-				case 'alpha': return color.setAlpha(arg);
-				case 'hue': return color.spin(arg);
-				case 'saturate': return color.saturate(arg);
-			}
-		}
-
-		// other case
-		return tinycolor(val);
-	}
-
-	const props = {};
-
-	for (const [k, v] of Object.entries(theme.props)) {
-		if (k.startsWith('$')) continue; // ignore const
-
-		props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
-	}
-
-	return props;
-}
-
-function genValue(c: tinycolor.Instance): string {
-	return c.toRgbString();
-}
-
-export function validateTheme(theme: Record<string, any>): boolean {
-	if (theme.id == null || typeof theme.id !== 'string') return false;
-	if (theme.name == null || typeof theme.name !== 'string') return false;
-	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
-	if (theme.props == null || typeof theme.props !== 'object') return false;
-	return true;
-}
-
-export function parseThemeCode(code: string): Theme {
-	let theme;
-
-	try {
-		theme = JSON5.parse(code);
-	} catch (err) {
-		throw new Error('Failed to parse theme json');
-	}
-	if (!validateTheme(theme)) {
-		throw new Error('This theme is invaild');
-	}
-	if (getThemes().some(t => t.id === theme.id)) {
-		throw new Error('This theme is already installed');
-	}
-
-	return theme;
-}
-
-export function previewTheme(code: string): void {
-	const theme = parseThemeCode(code);
-	if (theme) applyTheme(theme, false);
-}
-
-export async function installTheme(code: string): Promise<void> {
-	const theme = parseThemeCode(code);
-	if (!theme) return;
-	await addTheme(theme);
-}
diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts
deleted file mode 100644
index 275b67ed00..0000000000
--- a/packages/frontend/src/scripts/time.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-const dateTimeIntervals = {
-	'day': 86400000,
-	'hour': 3600000,
-	'ms': 1,
-};
-
-export function dateUTC(time: number[]): Date {
-	const d =
-		time.length === 2 ? Date.UTC(time[0], time[1])
-		: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
-		: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
-		: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
-		: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
-		: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
-		: null;
-
-	if (!d) throw new Error('wrong number of arguments');
-
-	return new Date(d);
-}
-
-export function isTimeSame(a: Date, b: Date): boolean {
-	return a.getTime() === b.getTime();
-}
-
-export function isTimeBefore(a: Date, b: Date): boolean {
-	return (a.getTime() - b.getTime()) < 0;
-}
-
-export function isTimeAfter(a: Date, b: Date): boolean {
-	return (a.getTime() - b.getTime()) > 0;
-}
-
-export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
-	return new Date(x.getTime() + (value * dateTimeIntervals[span]));
-}
-
-export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
-	return new Date(x.getTime() - (value * dateTimeIntervals[span]));
-}
diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts
deleted file mode 100644
index c7582e06da..0000000000
--- a/packages/frontend/src/scripts/timezones.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const timezones = [{
-	name: 'UTC',
-	abbrev: 'UTC',
-	offset: 0,
-}, {
-	name: 'Europe/Berlin',
-	abbrev: 'CET',
-	offset: 60,
-}, {
-	name: 'Asia/Tokyo',
-	abbrev: 'JST',
-	offset: 540,
-}, {
-	name: 'Asia/Seoul',
-	abbrev: 'KST',
-	offset: 540,
-}, {
-	name: 'Asia/Shanghai',
-	abbrev: 'CST',
-	offset: 480,
-}, {
-	name: 'Australia/Sydney',
-	abbrev: 'AEST',
-	offset: 600,
-}, {
-	name: 'Australia/Darwin',
-	abbrev: 'ACST',
-	offset: 570,
-}, {
-	name: 'Australia/Perth',
-	abbrev: 'AWST',
-	offset: 480,
-}, {
-	name: 'America/New_York',
-	abbrev: 'EST',
-	offset: -300,
-}, {
-	name: 'America/Mexico_City',
-	abbrev: 'CST',
-	offset: -360,
-}, {
-	name: 'America/Phoenix',
-	abbrev: 'MST',
-	offset: -420,
-}, {
-	name: 'America/Los_Angeles',
-	abbrev: 'PST',
-	offset: -480,
-}];
diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts
deleted file mode 100644
index 13c9d648dc..0000000000
--- a/packages/frontend/src/scripts/touch.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import { deviceKind } from '@/scripts/device-kind.js';
-
-const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
-
-export let isTouchUsing = deviceKind === 'tablet' || deviceKind === 'smartphone';
-
-if (isTouchSupported && !isTouchUsing) {
-	window.addEventListener('touchstart', () => {
-		// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
-		// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
-		isTouchUsing = true;
-	}, { passive: true });
-}
-
-/** (MkHorizontalSwipe) 横スワイプ中か？ */
-export const isHorizontalSwipeSwiping = ref(false);
diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts
deleted file mode 100644
index a24941d02e..0000000000
--- a/packages/frontend/src/scripts/unison-reload.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// SafariがBroadcastChannel未実装なのでライブラリを使う
-import { BroadcastChannel } from 'broadcast-channel';
-
-export const reloadChannel = new BroadcastChannel<string | null>('reload');
-
-// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
-export function unisonReload(path?: string) {
-	if (path !== undefined) {
-		reloadChannel.postMessage(path);
-		location.href = path;
-	} else {
-		reloadChannel.postMessage(null);
-		location.reload();
-	}
-}
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
deleted file mode 100644
index d105a318a7..0000000000
--- a/packages/frontend/src/scripts/upload.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { reactive, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { v4 as uuid } from 'uuid';
-import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
-import { apiUrl } from '@@/js/config.js';
-import { getCompressionConfig } from './upload/compress-config.js';
-import { $i } from '@/account.js';
-import { alert } from '@/os.js';
-import { i18n } from '@/i18n.js';
-import { instance } from '@/instance.js';
-import { prefer } from '@/preferences.js';
-
-type Uploading = {
-	id: string;
-	name: string;
-	progressMax: number | undefined;
-	progressValue: number | undefined;
-	img: string;
-};
-export const uploads = ref<Uploading[]>([]);
-
-const mimeTypeMap = {
-	'image/webp': 'webp',
-	'image/jpeg': 'jpg',
-	'image/png': 'png',
-} as const;
-
-export function uploadFile(
-	file: File,
-	folder?: string | Misskey.entities.DriveFolder,
-	name?: string,
-	keepOriginal: boolean = prefer.s.keepOriginalUploading,
-): Promise<Misskey.entities.DriveFile> {
-	if ($i == null) throw new Error('Not logged in');
-
-	const _folder = typeof folder === 'string' ? folder : folder?.id;
-
-	if (file.size > instance.maxFileSize) {
-		alert({
-			type: 'error',
-			title: i18n.ts.failedToUpload,
-			text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
-		});
-		return Promise.reject();
-	}
-
-	return new Promise((resolve, reject) => {
-		const id = uuid();
-
-		const reader = new FileReader();
-		reader.onload = async (): Promise<void> => {
-			const filename = name ?? file.name ?? 'untitled';
-			const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
-
-			const ctx = reactive<Uploading>({
-				id,
-				name: prefer.s.keepOriginalFilename ? filename : id + extension,
-				progressMax: undefined,
-				progressValue: undefined,
-				img: window.URL.createObjectURL(file),
-			});
-
-			uploads.value.push(ctx);
-
-			const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
-			let resizedImage: Blob | undefined;
-			if (config) {
-				try {
-					const resized = await readAndCompressImage(file, config);
-					if (resized.size < file.size || file.type === 'image/webp') {
-						// The compression may not always reduce the file size
-						// (and WebP is not browser safe yet)
-						resizedImage = resized;
-					}
-					if (_DEV_) {
-						const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
-						console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
-					}
-
-					ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
-				} catch (err) {
-					console.error('Failed to resize image', err);
-				}
-			}
-
-			const formData = new FormData();
-			formData.append('i', $i!.token);
-			formData.append('force', 'true');
-			formData.append('file', resizedImage ?? file);
-			formData.append('name', ctx.name);
-			if (_folder) formData.append('folderId', _folder);
-
-			const xhr = new XMLHttpRequest();
-			xhr.open('POST', apiUrl + '/drive/files/create', true);
-			xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
-				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
-					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
-					uploads.value = uploads.value.filter(x => x.id !== id);
-
-					if (xhr.status === 413) {
-						alert({
-							type: 'error',
-							title: i18n.ts.failedToUpload,
-							text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
-						});
-					} else if (ev.target?.response) {
-						const res = JSON.parse(ev.target.response);
-						if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: i18n.ts.cannotUploadBecauseInappropriate,
-							});
-						} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: i18n.ts.cannotUploadBecauseNoFreeSpace,
-							});
-						} else {
-							alert({
-								type: 'error',
-								title: i18n.ts.failedToUpload,
-								text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
-							});
-						}
-					} else {
-						alert({
-							type: 'error',
-							title: 'Failed to upload',
-							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
-						});
-					}
-
-					reject();
-					return;
-				}
-
-				const driveFile = JSON.parse(ev.target.response);
-
-				resolve(driveFile);
-
-				uploads.value = uploads.value.filter(x => x.id !== id);
-			}) as (ev: ProgressEvent<EventTarget>) => any;
-
-			xhr.upload.onprogress = ev => {
-				if (ev.lengthComputable) {
-					ctx.progressMax = ev.total;
-					ctx.progressValue = ev.loaded;
-				}
-			};
-
-			xhr.send(formData);
-		};
-		reader.readAsArrayBuffer(file);
-	});
-}
diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts
deleted file mode 100644
index 3046b7f518..0000000000
--- a/packages/frontend/src/scripts/upload/compress-config.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import isAnimated from 'is-file-animated';
-import { isWebpSupported } from './isWebpSupported.js';
-import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
-
-const compressTypeMap = {
-	'image/jpeg': { quality: 0.90, mimeType: 'image/webp' },
-	'image/png': { quality: 1, mimeType: 'image/webp' },
-	'image/webp': { quality: 0.90, mimeType: 'image/webp' },
-	'image/svg+xml': { quality: 1, mimeType: 'image/webp' },
-} as const;
-
-const compressTypeMapFallback = {
-	'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
-	'image/png': { quality: 1, mimeType: 'image/png' },
-	'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
-	'image/svg+xml': { quality: 1, mimeType: 'image/png' },
-} as const;
-
-export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
-	const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
-	if (!imgConfig || await isAnimated(file)) {
-		return;
-	}
-
-	return {
-		maxWidth: 2048,
-		maxHeight: 2048,
-		debug: true,
-		...imgConfig,
-	};
-}
diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/scripts/upload/isWebpSupported.ts
deleted file mode 100644
index 2511236ecc..0000000000
--- a/packages/frontend/src/scripts/upload/isWebpSupported.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-let isWebpSupportedCache: boolean | undefined;
-export function isWebpSupported() {
-	if (isWebpSupportedCache === undefined) {
-		const canvas = document.createElement('canvas');
-		canvas.width = 1;
-		canvas.height = 1;
-		isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp');
-	}
-	return isWebpSupportedCache;
-}
diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts
deleted file mode 100644
index bba64fc6ee..0000000000
--- a/packages/frontend/src/scripts/use-chart-tooltip.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted, onDeactivated, ref } from 'vue';
-import * as os from '@/os.js';
-import MkChartTooltip from '@/components/MkChartTooltip.vue';
-
-export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
-	const tooltipShowing = ref(false);
-	const tooltipX = ref(0);
-	const tooltipY = ref(0);
-	const tooltipTitle = ref<string | null>(null);
-	const tooltipSeries = ref<{
-		backgroundColor: string;
-		borderColor: string;
-		text: string;
-	}[] | null>(null);
-	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
-		showing: tooltipShowing,
-		x: tooltipX,
-		y: tooltipY,
-		title: tooltipTitle,
-		series: tooltipSeries,
-	}, {});
-
-	onUnmounted(() => {
-		disposeTooltipComponent();
-	});
-
-	onDeactivated(() => {
-		tooltipShowing.value = false;
-	});
-
-	function handler(context) {
-		if (context.tooltip.opacity === 0) {
-			tooltipShowing.value = false;
-			return;
-		}
-
-		tooltipTitle.value = context.tooltip.title[0];
-		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
-			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
-			borderColor: context.tooltip.labelColors[i].borderColor,
-			text: b.lines[0],
-		}));
-
-		const rect = context.chart.canvas.getBoundingClientRect();
-
-		tooltipShowing.value = true;
-		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
-		if (opts.position === 'top') {
-			tooltipY.value = rect.top + window.scrollY;
-		} else if (opts.position === 'middle') {
-			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
-		}
-	}
-
-	return {
-		handler,
-	};
-}
diff --git a/packages/frontend/src/scripts/use-form.ts b/packages/frontend/src/scripts/use-form.ts
deleted file mode 100644
index 26cca839c3..0000000000
--- a/packages/frontend/src/scripts/use-form.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, reactive, watch } from 'vue';
-import type { Reactive } from 'vue';
-
-function copy<T>(v: T): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-function unwrapReactive<T>(v: Reactive<T>): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
-	const currentState = reactive<T>(copy(initialState));
-	const previousState = reactive<T>(copy(initialState));
-
-	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
-	for (const key in currentState) {
-		modifiedStates[key] = false;
-	}
-	const modified = computed(() => Object.values(modifiedStates).some(v => v));
-	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
-
-	watch([currentState, previousState], () => {
-		for (const key in modifiedStates) {
-			modifiedStates[key] = currentState[key] !== previousState[key];
-		}
-	}, { deep: true });
-
-	async function _save() {
-		await save(unwrapReactive(currentState));
-		for (const key in currentState) {
-			previousState[key] = copy(currentState[key]);
-		}
-	}
-
-	function discard() {
-		for (const key in currentState) {
-			currentState[key] = copy(previousState[key]);
-		}
-	}
-
-	return {
-		state: currentState,
-		savedState: previousState,
-		modifiedStates,
-		modified,
-		modifiedCount,
-		save: _save,
-		discard,
-	};
-}
diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts
deleted file mode 100644
index 395c12a756..0000000000
--- a/packages/frontend/src/scripts/use-leave-guard.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Ref } from 'vue';
-
-export function useLeaveGuard(enabled: Ref<boolean>) {
-	/* TODO
-	const setLeaveGuard = inject('setLeaveGuard');
-
-	if (setLeaveGuard) {
-		setLeaveGuard(async () => {
-			if (!enabled.value) return false;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return canceled;
-		});
-	} else {
-		onBeforeRouteLeave(async (to, from) => {
-			if (!enabled.value) return true;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return !canceled;
-		});
-	}
-	*/
-
-	/*
-	function onBeforeLeave(ev: BeforeUnloadEvent) {
-		if (enabled.value) {
-			ev.preventDefault();
-			ev.returnValue = '';
-		}
-	}
-
-	window.addEventListener('beforeunload', onBeforeLeave);
-	onUnmounted(() => {
-		window.removeEventListener('beforeunload', onBeforeLeave);
-	});
-	*/
-}
diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts
deleted file mode 100644
index 0bc10e90e4..0000000000
--- a/packages/frontend/src/scripts/use-note-capture.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
-import * as Misskey from 'misskey-js';
-import { useStream } from '@/stream.js';
-import { $i } from '@/account.js';
-
-export function useNoteCapture(props: {
-	rootEl: ShallowRef<HTMLElement | undefined>;
-	note: Ref<Misskey.entities.Note>;
-	pureNote: Ref<Misskey.entities.Note>;
-	isDeletedRef: Ref<boolean>;
-}) {
-	const note = props.note;
-	const pureNote = props.pureNote;
-	const connection = $i ? useStream() : null;
-
-	function onStreamNoteUpdated(noteData): void {
-		const { type, id, body } = noteData;
-
-		if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
-
-		switch (type) {
-			case 'reacted': {
-				const reaction = body.reaction;
-
-				if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
-					note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
-				}
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = currentCount + 1;
-				note.value.reactionCount += 1;
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = reaction;
-				}
-				break;
-			}
-
-			case 'unreacted': {
-				const reaction = body.reaction;
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
-				note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
-				if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = null;
-				}
-				break;
-			}
-
-			case 'pollVoted': {
-				const choice = body.choice;
-
-				const choices = [...note.value.poll.choices];
-				choices[choice] = {
-					...choices[choice],
-					votes: choices[choice].votes + 1,
-					...($i && (body.userId === $i.id) ? {
-						isVoted: true,
-					} : {}),
-				};
-
-				note.value.poll.choices = choices;
-				break;
-			}
-
-			case 'deleted': {
-				props.isDeletedRef.value = true;
-				break;
-			}
-		}
-	}
-
-	function capture(withHandler = false): void {
-		if (connection) {
-			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-			connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
-			if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
-			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function decapture(withHandler = false): void {
-		if (connection) {
-			connection.send('un', {
-				id: note.value.id,
-			});
-			if (pureNote.value.id !== note.value.id) {
-				connection.send('un', {
-					id: pureNote.value.id,
-				});
-			}
-			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function onStreamConnected() {
-		capture(false);
-	}
-
-	capture(true);
-	if (connection) {
-		connection.on('_connected_', onStreamConnected);
-	}
-
-	onUnmounted(() => {
-		decapture(true);
-		if (connection) {
-			connection.off('_connected_', onStreamConnected);
-		}
-	});
-}
diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts
deleted file mode 100644
index d9ddfc8b5d..0000000000
--- a/packages/frontend/src/scripts/use-tooltip.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref, watch, onUnmounted } from 'vue';
-import type { Ref } from 'vue';
-
-export function useTooltip(
-	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
-	onShow: (showing: Ref<boolean>) => void,
-	delay = 300,
-): void {
-	let isHovering = false;
-
-	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
-	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
-	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
-	let shouldIgnoreMouseover = false;
-
-	let timeoutId: number;
-
-	let changeShowingState: (() => void) | null;
-
-	let autoHidingTimer;
-
-	const open = () => {
-		close();
-		if (!isHovering) return;
-		if (elRef.value == null) return;
-		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
-
-		const showing = ref(true);
-		onShow(showing);
-		changeShowingState = () => {
-			showing.value = false;
-		};
-
-		autoHidingTimer = window.setInterval(() => {
-			if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
-				if (!isHovering) return;
-				isHovering = false;
-				window.clearTimeout(timeoutId);
-				close();
-				window.clearInterval(autoHidingTimer);
-			}
-		}, 1000);
-	};
-
-	const close = () => {
-		if (changeShowingState != null) {
-			changeShowingState();
-			changeShowingState = null;
-		}
-	};
-
-	const onMouseover = () => {
-		if (isHovering) return;
-		if (shouldIgnoreMouseover) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onMouseleave = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const onTouchstart = () => {
-		shouldIgnoreMouseover = true;
-		if (isHovering) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onTouchend = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const stop = watch(elRef, () => {
-		if (elRef.value) {
-			stop();
-			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-			el.addEventListener('mouseover', onMouseover, { passive: true });
-			el.addEventListener('mouseleave', onMouseleave, { passive: true });
-			el.addEventListener('touchstart', onTouchstart, { passive: true });
-			el.addEventListener('touchend', onTouchend, { passive: true });
-			el.addEventListener('click', close, { passive: true });
-		}
-	}, {
-		immediate: true,
-		flush: 'post',
-	});
-
-	onUnmounted(() => {
-		close();
-	});
-}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6e4b4cd0c1..b6bf0e5fba 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -8,12 +8,12 @@ import * as Misskey from 'misskey-js';
 import lightTheme from '@@/themes/l-light.json5';
 import darkTheme from '@@/themes/d-green-lime.json5';
 import { hemisphere } from '@@/js/intl-const.js';
-import type { DeviceKind } from '@/scripts/device-kind.js';
+import type { DeviceKind } from '@/utility/device-kind.js';
 import type { Plugin } from '@/plugin.js';
 import type { Column } from '@/deck.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { Storage } from '@/pizzax.js';
-import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
+import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
 
 interface PostFormAction {
 	title: string,
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index e63dac951c..e194e96a7f 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -8,7 +8,7 @@ import { markRaw } from 'vue';
 import { $i } from '@/account.js';
 import { wsOrigin } from '@@/js/config.js';
 // TODO: No WebsocketモードでStreamMockが使えそう
-//import { StreamMock } from '@/scripts/stream-mock.js';
+//import { StreamMock } from '@/utility/stream-mock.js';
 
 // heart beat interval in ms
 const HEART_BEAT_INTERVAL = 1000 * 60;
diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts
index 09c665a2ab..93fbe395f9 100644
--- a/packages/frontend/src/theme-store.ts
+++ b/packages/frontend/src/theme-store.ts
@@ -3,8 +3,8 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import type { Theme } from '@/scripts/theme.js';
-import { getBuiltinThemes } from '@/scripts/theme.js';
+import type { Theme } from '@/utility/theme.js';
+import { getBuiltinThemes } from '@/utility/theme.js';
 import { $i } from '@/account.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 51645f9676..e218cd8c62 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -50,9 +50,9 @@ import * as Misskey from 'misskey-js';
 import { swInject } from './sw-inject.js';
 import XNotification from './notification.vue';
 import { popups } from '@/os.js';
-import { pendingApiRequestsCount } from '@/scripts/misskey-api.js';
-import { uploads } from '@/scripts/upload.js';
-import * as sound from '@/scripts/sound.js';
+import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
+import { uploads } from '@/utility/upload.js';
+import * as sound from '@/utility/sound.js';
 import { $i } from '@/account.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 1fb99f9f22..1797007f4a 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -97,7 +97,7 @@ import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
+import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
 import { useRouter } from '@/router/supplier.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
index e234bb3a33..16e72fa227 100644
--- a/packages/frontend/src/ui/_common_/statusbar-federation.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MarqueeText from '@/components/MkMarquee.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const props = defineProps<{
 	display?: 'marquee' | 'oneByOne';
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index da8fa8bb21..4da89a181e 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -31,7 +31,7 @@ import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MarqueeText from '@/components/MkMarquee.vue';
 import { useInterval } from '@@/js/use-interval.js';
-import { shuffle } from '@/scripts/shuffle.js';
+import { shuffle } from '@/utility/shuffle.js';
 
 const props = defineProps<{
 	url?: string;
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
index 078b595dca..c5bee51162 100644
--- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MarqueeText from '@/components/MkMarquee.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
-import { getNoteSummary } from '@/scripts/get-note-summary.js';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
 import { notePage } from '@/filters/note.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts
index ff851ad99f..df392c6532 100644
--- a/packages/frontend/src/ui/_common_/sw-inject.ts
+++ b/packages/frontend/src/ui/_common_/sw-inject.ts
@@ -4,10 +4,10 @@
  */
 
 import { post } from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { $i, login } from '@/account.js';
-import { getAccountFromId } from '@/scripts/get-account-from-id.js';
-import { deepClone } from '@/scripts/clone.js';
+import { getAccountFromId } from '@/utility/get-account-from-id.js';
+import { deepClone } from '@/utility/clone.js';
 import { mainRouter } from '@/router/main.js';
 
 export function swInject() {
diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue
index c7d1387eae..3e5653e46d 100644
--- a/packages/frontend/src/ui/_common_/upload.vue
+++ b/packages/frontend/src/ui/_common_/upload.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { } from 'vue';
 import * as os from '@/os.js';
-import { uploads } from '@/scripts/upload.js';
+import { uploads } from '@/utility/upload.js';
 import { i18n } from '@/i18n.js';
 
 const zIndex = os.claimZIndex('high');
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index c11771f028..747fa2ab85 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -56,7 +56,7 @@ import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
-// import { StickySidebar } from '@/scripts/sticky-sidebar.js';
+// import { StickySidebar } from '@/utility/sticky-sidebar.js';
 // import { mainRouter } from '@/router.js';
 //import MisskeyLogo from '@assets/client/misskey.svg';
 import { store } from '@/store.js';
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 63c60a3d6f..abe612ab3e 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -51,10 +51,10 @@ import { instanceName } from '@@/js/config.js';
 import { isLink } from '@@/js/is-link.js';
 import XSidebar from './classic.sidebar.vue';
 import XCommon from './_common_/common.vue';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
-import { StickySidebar } from '@/scripts/sticky-sidebar.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
+import { StickySidebar } from '@/utility/sticky-sidebar.js';
 import * as os from '@/os.js';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 2107f6f53a..b723f92ad6 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -103,8 +103,8 @@ import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { unisonReload } from '@/utility/unison-reload.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import { prefer } from '@/preferences.js';
 import XMainColumn from '@/ui/deck/main-column.vue';
 import XTlColumn from '@/ui/deck/tl-column.vue';
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index 5f0c607edb..ae282ba324 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -23,11 +23,11 @@ import type { SoundStore } from '@/preferences/def.js';
 import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { antennasCache } from '@/cache.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 
 const props = defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index f002f655f7..964b42874e 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -30,10 +30,10 @@ import MkTimeline from '@/components/MkTimeline.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { favoritedChannelsCache } from '@/cache.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 
 const props = defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index f627ab5262..2b4e86b8a2 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -23,11 +23,11 @@ import type { SoundStore } from '@/preferences/def.js';
 import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { userListsCache } from '@/cache.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 
 const props = defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index 906e94f0b3..bab938af20 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -24,10 +24,10 @@ import { getScrollContainer } from '@@/js/scroll.js';
 import { isLink } from '@@/js/is-link.js';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
 import { useScrollPositionManager } from '@/nirax.js';
 import { mainRouter } from '@/router/main.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 4d1be08706..5d07440017 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -22,10 +22,10 @@ import type { SoundStore } from '@/preferences/def.js';
 import { updateColumn } from '@/deck.js';
 import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 
 const props = defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index 96da944398..2dec7bf5aa 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -43,7 +43,7 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
-import * as sound from '@/scripts/sound.js';
+import * as sound from '@/utility/sound.js';
 
 const props = defineProps<{
 	column: Column;
diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts
index 277ebb0511..728c0d0d29 100644
--- a/packages/frontend/src/ui/deck/tl-note-notification.ts
+++ b/packages/frontend/src/ui/deck/tl-note-notification.ts
@@ -5,9 +5,9 @@
 
 import * as Misskey from 'misskey-js';
 import type { Ref } from 'vue';
-import type { SoundType } from '@/scripts/sound.js';
+import type { SoundType } from '@/utility/sound.js';
 import type { SoundStore } from '@/preferences/def.js';
-import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/scripts/sound.js';
+import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/utility/sound.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 
diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue
index 5cbcd69b2c..919a6f2246 100644
--- a/packages/frontend/src/ui/minimum.vue
+++ b/packages/frontend/src/ui/minimum.vue
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, provide, ref } from 'vue';
 import XCommon from './_common_/common.vue';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import { instanceName } from '@@/js/config.js';
 import { mainRouter } from '@/router/main.js';
 
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index bddb62dc60..e012d615a8 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -103,15 +103,15 @@ import { isLink } from '@@/js/is-link.js';
 import XCommon from './_common_/common.vue';
 import type { Ref } from 'vue';
 import type MkStickyContainer from '@/components/global/MkStickyContainer.vue';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { navbarItemDef } from '@/navbar.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import { deviceKind } from '@/scripts/device-kind.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
+import { deviceKind } from '@/utility/device-kind.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { useScrollPositionManager } from '@/nirax.js';
 import { mainRouter } from '@/router/main.js';
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 9ba9786ca8..3125f74922 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -72,12 +72,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, provide, ref, computed } from 'vue';
 import { instanceName } from '@@/js/config.js';
 import XCommon from './_common_/common.vue';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import * as os from '@/os.js';
 import { instance } from '@/instance.js';
 import XSigninDialog from '@/components/MkSigninDialog.vue';
 import XSignupDialog from '@/components/MkSignupDialog.vue';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
 import { mainRouter } from '@/router/main.js';
diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue
index 2e31d056c1..f4db5d30fe 100644
--- a/packages/frontend/src/ui/zen.vue
+++ b/packages/frontend/src/ui/zen.vue
@@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, provide, ref } from 'vue';
 import XCommon from './_common_/common.vue';
-import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
+import { provideMetadataReceiver, provideReactiveMetadata } from '@/utility/page-metadata.js';
+import type { PageMetadata } from '@/utility/page-metadata.js';
 import { instanceName, ui } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { mainRouter } from '@/router/main.js';
diff --git a/packages/frontend/src/utility/achievements.ts b/packages/frontend/src/utility/achievements.ts
new file mode 100644
index 0000000000..3025a985ba
--- /dev/null
+++ b/packages/frontend/src/utility/achievements.ts
@@ -0,0 +1,513 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { $i } from '@/account.js';
+
+export const ACHIEVEMENT_TYPES = [
+	'notes1',
+	'notes10',
+	'notes100',
+	'notes500',
+	'notes1000',
+	'notes5000',
+	'notes10000',
+	'notes20000',
+	'notes30000',
+	'notes40000',
+	'notes50000',
+	'notes60000',
+	'notes70000',
+	'notes80000',
+	'notes90000',
+	'notes100000',
+	'login3',
+	'login7',
+	'login15',
+	'login30',
+	'login60',
+	'login100',
+	'login200',
+	'login300',
+	'login400',
+	'login500',
+	'login600',
+	'login700',
+	'login800',
+	'login900',
+	'login1000',
+	'passedSinceAccountCreated1',
+	'passedSinceAccountCreated2',
+	'passedSinceAccountCreated3',
+	'loggedInOnBirthday',
+	'loggedInOnNewYearsDay',
+	'noteClipped1',
+	'noteFavorited1',
+	'myNoteFavorited1',
+	'profileFilled',
+	'markedAsCat',
+	'following1',
+	'following10',
+	'following50',
+	'following100',
+	'following300',
+	'followers1',
+	'followers10',
+	'followers50',
+	'followers100',
+	'followers300',
+	'followers500',
+	'followers1000',
+	'collectAchievements30',
+	'viewAchievements3min',
+	'iLoveMisskey',
+	'foundTreasure',
+	'client30min',
+	'client60min',
+	'noteDeletedWithin1min',
+	'postedAtLateNight',
+	'postedAt0min0sec',
+	'selfQuote',
+	'htl20npm',
+	'viewInstanceChart',
+	'outputHelloWorldOnScratchpad',
+	'open3windows',
+	'driveFolderCircularReference',
+	'reactWithoutRead',
+	'clickedClickHere',
+	'justPlainLucky',
+	'setNameToSyuilo',
+	'cookieClicked',
+	'brainDiver',
+	'smashTestNotificationButton',
+	'tutorialCompleted',
+	'bubbleGameExplodingHead',
+	'bubbleGameDoubleExplodingHead',
+] as const;
+
+export const ACHIEVEMENT_BADGES = {
+	'notes1': {
+		img: '/fluent-emoji/1f4dd.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes10': {
+		img: '/fluent-emoji/1f4d1.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'notes100': {
+		img: '/fluent-emoji/1f4d2.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes500': {
+		img: '/fluent-emoji/1f4da.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'notes1000': {
+		img: '/fluent-emoji/1f5c3.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes5000': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes10000': {
+		img: '/fluent-emoji/1f3d9.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'silver',
+	},
+	'notes20000': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'silver',
+	},
+	'notes30000': {
+		img: '/fluent-emoji/1f306.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'notes40000': {
+		img: '/fluent-emoji/1f303.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'silver',
+	},
+	'notes50000': {
+		img: '/fluent-emoji/1fa90.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'gold',
+	},
+	'notes60000': {
+		img: '/fluent-emoji/2604.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'gold',
+	},
+	'notes70000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'gold',
+	},
+	'notes80000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'gold',
+	},
+	'notes90000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'gold',
+	},
+	'notes100000': {
+		img: '/fluent-emoji/267e.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'platinum',
+	},
+	'login3': {
+		img: '/fluent-emoji/1f331.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'login7': {
+		img: '/fluent-emoji/1f331.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'login15': {
+		img: '/fluent-emoji/1f331.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'bronze',
+	},
+	'login30': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'login60': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'login100': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'silver',
+	},
+	'login200': {
+		img: '/fluent-emoji/1f333.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'login300': {
+		img: '/fluent-emoji/1f333.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'silver',
+	},
+	'login400': {
+		img: '/fluent-emoji/1f333.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'silver',
+	},
+	'login500': {
+		img: '/fluent-emoji/1f304.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'login600': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'gold',
+	},
+	'login700': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'gold',
+	},
+	'login800': {
+		img: '/fluent-emoji/1f307.png',
+		bg: null,
+		frame: 'gold',
+	},
+	'login900': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'gold',
+	},
+	'login1000': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'platinum',
+	},
+	'noteClipped1': {
+		img: '/fluent-emoji/1f587.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'noteFavorited1': {
+		img: '/fluent-emoji/1f31f.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'myNoteFavorited1': {
+		img: '/fluent-emoji/1f320.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'profileFilled': {
+		img: '/fluent-emoji/1f44c.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'markedAsCat': {
+		img: '/fluent-emoji/1f408.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'following1': {
+		img: '/fluent-emoji/2618.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following10': {
+		img: '/fluent-emoji/1f6b8.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following50': {
+		img: '/fluent-emoji/1f91d.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following100': {
+		img: '/fluent-emoji/1f4af.png',
+		bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))',
+		frame: 'silver',
+	},
+	'following300': {
+		img: '/fluent-emoji/1f970.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers1': {
+		img: '/fluent-emoji/2618.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'followers10': {
+		img: '/fluent-emoji/1f44b.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'followers50': {
+		img: '/fluent-emoji/1f411.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'followers100': {
+		img: '/fluent-emoji/1f60e.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers300': {
+		img: '/fluent-emoji/1f3c6.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers500': {
+		img: '/fluent-emoji/1f4e1.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'gold',
+	},
+	'followers1000': {
+		img: '/fluent-emoji/1f451.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'platinum',
+	},
+	'collectAchievements30': {
+		img: '/fluent-emoji/1f3c5.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+	'viewAchievements3min': {
+		img: '/fluent-emoji/1f3c5.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'iLoveMisskey': {
+		img: '/fluent-emoji/2764.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+	'foundTreasure': {
+		img: '/fluent-emoji/1f3c6.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'gold',
+	},
+	'client30min': {
+		img: '/fluent-emoji/1f552.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'client60min': {
+		img: '/fluent-emoji/1f552.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'silver',
+	},
+	'noteDeletedWithin1min': {
+		img: '/fluent-emoji/1f5d1.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'postedAtLateNight': {
+		img: '/fluent-emoji/1f319.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'bronze',
+	},
+	'postedAt0min0sec': {
+		img: '/fluent-emoji/1f55b.png',
+		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
+		frame: 'bronze',
+	},
+	'selfQuote': {
+		img: '/fluent-emoji/1f4dd.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'htl20npm': {
+		img: '/fluent-emoji/1f30a.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'viewInstanceChart': {
+		img: '/fluent-emoji/1f4ca.png',
+		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
+		frame: 'bronze',
+	},
+	'outputHelloWorldOnScratchpad': {
+		img: '/fluent-emoji/1f530.png',
+		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
+		frame: 'bronze',
+	},
+	'open3windows': {
+		img: '/fluent-emoji/1f5a5.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'driveFolderCircularReference': {
+		img: '/fluent-emoji/1f4c2.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'reactWithoutRead': {
+		img: '/fluent-emoji/2753.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'clickedClickHere': {
+		img: '/fluent-emoji/2757.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'justPlainLucky': {
+		img: '/fluent-emoji/1f340.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'silver',
+	},
+	'setNameToSyuilo': {
+		img: '/fluent-emoji/1f36e.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'passedSinceAccountCreated1': {
+		img: '/fluent-emoji/0031-20e3.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'passedSinceAccountCreated2': {
+		img: '/fluent-emoji/0032-20e3.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'passedSinceAccountCreated3': {
+		img: '/fluent-emoji/0033-20e3.png',
+		bg: null,
+		frame: 'gold',
+	},
+	'loggedInOnBirthday': {
+		img: '/fluent-emoji/1f382.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+	'loggedInOnNewYearsDay': {
+		img: '/fluent-emoji/1f38d.png',
+		bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))',
+		frame: 'silver',
+	},
+	'cookieClicked': {
+		img: '/fluent-emoji/1f36a.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'brainDiver': {
+		img: '/fluent-emoji/1f9e0.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'bronze',
+	},
+	'smashTestNotificationButton': {
+		img: '/fluent-emoji/1f514.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'tutorialCompleted': {
+		img: '/fluent-emoji/1f393.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'bubbleGameExplodingHead': {
+		img: '/fluent-emoji/1f92f.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'bronze',
+	},
+	'bubbleGameDoubleExplodingHead': {
+		img: '/fluent-emoji/1f92f.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
+} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
+	img: string;
+	bg: string | null;
+	frame: 'bronze' | 'silver' | 'gold' | 'platinum';
+}>;
+ */
+} as const;
+
+export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
+
+const claimingQueue = new Set<string>();
+
+export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
+	if ($i == null) return;
+	if ($i.movedTo) return;
+	if (claimedAchievements.includes(type)) return;
+	claimingQueue.add(type);
+	claimedAchievements.push(type);
+	await new Promise(resolve => setTimeout(resolve, (claimingQueue.size - 1) * 500));
+	window.setTimeout(() => {
+		claimingQueue.delete(type);
+	}, 500);
+	misskeyApi('i/claim-achievement', { name: type });
+}
+
+if (_DEV_) {
+	(window as any).unlockAllAchievements = () => {
+		for (const t of ACHIEVEMENT_TYPES) {
+			claimAchievement(t);
+		}
+	};
+}
diff --git a/packages/frontend/src/utility/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts
new file mode 100644
index 0000000000..7405e229fe
--- /dev/null
+++ b/packages/frontend/src/utility/admin-lookup.ts
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+
+export async function lookupUser() {
+	const { canceled, result } = await os.inputText({
+		title: i18n.ts.usernameOrUserId,
+	});
+	if (canceled) return;
+
+	const show = (user) => {
+		os.pageWindow(`/admin/user/${user.id}`);
+	};
+
+	const usernamePromise = misskeyApi('users/show', Misskey.acct.parse(result));
+	const idPromise = misskeyApi('users/show', { userId: result });
+	let _notFound = false;
+	const notFound = () => {
+		if (_notFound) {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.noSuchUser,
+			});
+		} else {
+			_notFound = true;
+		}
+	};
+	usernamePromise.then(show).catch(err => {
+		if (err.code === 'NO_SUCH_USER') {
+			notFound();
+		}
+	});
+	idPromise.then(show).catch(err => {
+		notFound();
+	});
+}
+
+export async function lookupUserByEmail() {
+	const { canceled, result } = await os.inputText({
+		title: i18n.ts.emailAddress,
+		type: 'email',
+	});
+	if (canceled) return;
+
+	try {
+		const user = await os.apiWithDialog('admin/accounts/find-by-email', { email: result });
+
+		os.pageWindow(`/admin/user/${user.id}`);
+	} catch (err) {
+		if (err.code === 'USER_NOT_FOUND') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.noSuchUser,
+			});
+		} else {
+			throw err;
+		}
+	}
+}
+
+export async function lookupFile() {
+	const { canceled, result: q } = await os.inputText({
+		title: i18n.ts.fileIdOrUrl,
+		minLength: 1,
+	});
+	if (canceled) return;
+
+	const show = (file) => {
+		os.pageWindow(`/admin/file/${file.id}`);
+	};
+
+	misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+		show(file);
+	}).catch(err => {
+		if (err.code === 'NO_SUCH_FILE') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.notFound,
+			});
+		}
+	});
+}
diff --git a/packages/frontend/src/utility/array.ts b/packages/frontend/src/utility/array.ts
new file mode 100644
index 0000000000..f2feb29dfc
--- /dev/null
+++ b/packages/frontend/src/utility/array.ts
@@ -0,0 +1,118 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+type EndoRelation<T> = (a: T, b: T) => boolean;
+type Predicate<T> = (x: T) => boolean;
+
+/**
+ * Count the number of elements that satisfy the predicate
+ */
+
+export function countIf<T>(f: Predicate<T>, xs: T[]): number {
+	return xs.filter(f).length;
+}
+
+/**
+ * Count the number of elements that is equal to the element
+ */
+export function count<T>(a: T, xs: T[]): number {
+	return countIf(x => x === a, xs);
+}
+
+/**
+ * Concatenate an array of arrays
+ */
+export function concat<T>(xss: T[][]): T[] {
+	return ([] as T[]).concat(...xss);
+}
+
+/**
+ * Intersperse the element between the elements of the array
+ * @param sep The element to be interspersed
+ */
+export function intersperse<T>(sep: T, xs: T[]): T[] {
+	return concat(xs.map(x => [sep, x])).slice(1);
+}
+
+/**
+ * Returns the array of elements that is not equal to the element
+ */
+export function erase<T>(a: T, xs: T[]): T[] {
+	return xs.filter(x => x !== a);
+}
+
+/**
+ * Finds the array of all elements in the first array not contained in the second array.
+ * The order of result values are determined by the first array.
+ */
+export function difference<T>(xs: T[], ys: T[]): T[] {
+	return xs.filter(x => !ys.includes(x));
+}
+
+/**
+ * Remove all but the first element from every group of equivalent elements
+ */
+export function unique<T>(xs: T[]): T[] {
+	return [...new Set(xs)];
+}
+
+export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
+	const map = new Map<TKey, TValue>();
+
+	for (const value of values) {
+		const key = keySelector(value);
+		if (!map.has(key)) map.set(key, value);
+	}
+
+	return [...map.values()];
+}
+
+export function sum(xs: number[]): number {
+	return xs.reduce((a, b) => a + b, 0);
+}
+
+export function maximum(xs: number[]): number {
+	return Math.max(...xs);
+}
+
+/**
+ * Compare two arrays by lexicographical order
+ */
+export function lessThan(xs: number[], ys: number[]): boolean {
+	for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
+		if (xs[i] < ys[i]) return true;
+		if (xs[i] > ys[i]) return false;
+	}
+	return xs.length < ys.length;
+}
+
+/**
+ * Returns the longest prefix of elements that satisfy the predicate
+ */
+export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
+	const ys: T[] = [];
+	for (const x of xs) {
+		if (f(x)) {
+			ys.push(x);
+		} else {
+			break;
+		}
+	}
+	return ys;
+}
+
+export function cumulativeSum(xs: number[]): number[] {
+	const ys = Array.from(xs); // deep copy
+	for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
+	return ys;
+}
+
+export function toArray<T>(x: T | T[] | undefined): T[] {
+	return Array.isArray(x) ? x : x != null ? [x] : [];
+}
+
+export function toSingle<T>(x: T | T[] | undefined): T | undefined {
+	return Array.isArray(x) ? x[0] : x;
+}
diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts
new file mode 100644
index 0000000000..9a603b848c
--- /dev/null
+++ b/packages/frontend/src/utility/autocomplete.ts
@@ -0,0 +1,323 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { nextTick, ref, defineAsyncComponent } from 'vue';
+import getCaretCoordinates from 'textarea-caret';
+import { toASCII } from 'punycode.js';
+import type { Ref } from 'vue';
+import { popup } from '@/os.js';
+
+export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
+
+export class Autocomplete {
+	private suggestion: {
+		x: Ref<number>;
+		y: Ref<number>;
+		q: Ref<any>;
+		close: () => void;
+	} | null;
+	private textarea: HTMLInputElement | HTMLTextAreaElement;
+	private currentType: string;
+	private textRef: Ref<string | number | null>;
+	private opening: boolean;
+	private onlyType: SuggestionType[];
+
+	private get text(): string {
+		// Use raw .value to get the latest value
+		// (Because v-model does not update while composition)
+		return this.textarea.value;
+	}
+
+	private set text(text: string) {
+		// Use ref value to notify other watchers
+		// (Because .value setter never fires input/change events)
+		this.textRef.value = text;
+	}
+
+	/**
+	 * 対象のテキストエリアを与えてインスタンスを初期化します。
+	 */
+	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string | number | null>, onlyType?: SuggestionType[]) {
+		//#region BIND
+		this.onInput = this.onInput.bind(this);
+		this.complete = this.complete.bind(this);
+		this.close = this.close.bind(this);
+		//#endregion
+
+		this.suggestion = null;
+		this.textarea = textarea;
+		this.textRef = textRef;
+		this.opening = false;
+		this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam'];
+
+		this.attach();
+	}
+
+	/**
+	 * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
+	 */
+	public attach() {
+		this.textarea.addEventListener('input', this.onInput);
+	}
+
+	/**
+	 * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
+	 */
+	public detach() {
+		this.textarea.removeEventListener('input', this.onInput);
+		this.close();
+	}
+
+	/**
+	 * テキスト入力時
+	 */
+	private onInput() {
+		const caretPos = this.textarea.selectionStart;
+		const text = this.text.substring(0, caretPos).split('\n').pop()!;
+
+		const mentionIndex = text.lastIndexOf('@');
+		const hashtagIndex = text.lastIndexOf('#');
+		const emojiIndex = text.lastIndexOf(':');
+		const mfmTagIndex = text.lastIndexOf('$');
+		const mfmParamIndex = text.lastIndexOf('.');
+
+		const max = Math.max(
+			mentionIndex,
+			hashtagIndex,
+			emojiIndex,
+			mfmTagIndex);
+
+		if (max === -1) {
+			this.close();
+			return;
+		}
+
+		const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
+
+		const isMention = mentionIndex !== -1;
+		const isHashtag = hashtagIndex !== -1;
+		const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
+		const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
+		const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
+
+		let opened = false;
+
+		if (isMention && this.onlyType.includes('user')) {
+			// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
+			// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
+			// ホスト名を含むリモートのユーザ名を全て拾えるようにする
+			const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
+			const username = mentionIndexAlt === -1
+				? text.substring(mentionIndex + 1)
+				: text.substring(mentionIndexAlt + 1);
+			if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
+				this.open('user', username);
+				opened = true;
+			} else if (username === '') {
+				this.open('user', null);
+				opened = true;
+			}
+		}
+
+		if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
+			const hashtag = text.substring(hashtagIndex + 1);
+			if (!hashtag.includes(' ')) {
+				this.open('hashtag', hashtag);
+				opened = true;
+			}
+		}
+
+		if (isEmoji && !opened && this.onlyType.includes('emoji')) {
+			const emoji = text.substring(emojiIndex + 1);
+			if (!emoji.includes(' ')) {
+				this.open('emoji', emoji);
+				opened = true;
+			}
+		}
+
+		if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
+			const mfmTag = text.substring(mfmTagIndex + 1);
+			if (!mfmTag.includes(' ')) {
+				this.open('mfmTag', mfmTag.replace('[', ''));
+				opened = true;
+			}
+		}
+
+		if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) {
+			const mfmParam = text.substring(mfmParamIndex + 1);
+			if (!mfmParam.includes(' ')) {
+				this.open('mfmParam', {
+					tag: text.substring(mfmTagIndex + 2, mfmParamIndex),
+					params: mfmParam.split(','),
+				});
+				opened = true;
+			}
+		}
+
+		if (!opened) {
+			this.close();
+		}
+	}
+
+	/**
+	 * サジェストを提示します。
+	 */
+	private async open(type: string, q: any) {
+		if (type !== this.currentType) {
+			this.close();
+		}
+		if (this.opening) return;
+		this.opening = true;
+		this.currentType = type;
+
+		//#region サジェストを表示すべき位置を計算
+		const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
+
+		const rect = this.textarea.getBoundingClientRect();
+
+		const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
+		const y = rect.top + caretPosition.top - this.textarea.scrollTop;
+		//#endregion
+
+		if (this.suggestion) {
+			this.suggestion.x.value = x;
+			this.suggestion.y.value = y;
+			this.suggestion.q.value = q;
+
+			this.opening = false;
+		} else {
+			const _x = ref(x);
+			const _y = ref(y);
+			const _q = ref(q);
+
+			const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
+				textarea: this.textarea,
+				close: this.close,
+				type: type,
+				q: _q,
+				x: _x,
+				y: _y,
+			}, {
+				done: (res) => {
+					this.complete(res);
+				},
+			});
+
+			this.suggestion = {
+				q: _q,
+				x: _x,
+				y: _y,
+				close: () => dispose(),
+			};
+
+			this.opening = false;
+		}
+	}
+
+	/**
+	 * サジェストを閉じます。
+	 */
+	private close() {
+		if (this.suggestion == null) return;
+
+		this.suggestion.close();
+		this.suggestion = null;
+
+		this.textarea.focus();
+	}
+
+	/**
+	 * オートコンプリートする
+	 */
+	private complete({ type, value }) {
+		this.close();
+
+		const caret = this.textarea.selectionStart;
+
+		if (type === 'user') {
+			const source = this.text;
+
+			const before = source.substring(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
+			const after = source.substring(caret);
+
+			const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
+
+			// 挿入
+			this.text = `${trimmedBefore}@${acct} ${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (acct.length + 2);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'hashtag') {
+			const source = this.text;
+
+			const before = source.substring(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
+			const after = source.substring(caret);
+
+			// 挿入
+			this.text = `${trimmedBefore}#${value} ${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (value.length + 2);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'emoji') {
+			const source = this.text;
+
+			const before = source.substring(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
+			const after = source.substring(caret);
+
+			// 挿入
+			this.text = trimmedBefore + value + after;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + value.length;
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'mfmTag') {
+			const source = this.text;
+
+			const before = source.substring(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
+			const after = source.substring(caret);
+
+			// 挿入
+			this.text = `${trimmedBefore}$[${value} ]${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (value.length + 3);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		} else if (type === 'mfmParam') {
+			const source = this.text;
+
+			const before = source.substring(0, caret);
+			const trimmedBefore = before.substring(0, before.lastIndexOf('.'));
+			const after = source.substring(caret);
+
+			// 挿入
+			this.text = `${trimmedBefore}.${value}${after}`;
+
+			// キャレットを戻す
+			nextTick(() => {
+				this.textarea.focus();
+				const pos = trimmedBefore.length + (value.length + 1);
+				this.textarea.setSelectionRange(pos, pos);
+			});
+		}
+	}
+}
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
new file mode 100644
index 0000000000..8e2a896b17
--- /dev/null
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -0,0 +1,822 @@
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// This file was automatically generated by create-search-index.
+// Do not edit this file.
+
+import { i18n } from '@/i18n.js';
+
+export type SearchIndexItem = {
+	id: string;
+	path?: string;
+	label: string;
+	keywords: string[];
+	icon?: string;
+	children?: SearchIndexItem[];
+};
+
+export const searchIndexes: SearchIndexItem[] = [
+	{
+		id: 'flXd1LC7r',
+		children: [
+			{
+				id: 'hB11H5oul',
+				label: i18n.ts.syncDeviceDarkMode,
+				keywords: ['sync', 'device', 'dark', 'light', 'mode'],
+			},
+			{
+				id: 'fDbLtIKeo',
+				label: i18n.ts.themeForLightMode,
+				keywords: ['light', 'theme'],
+			},
+			{
+				id: 'eLOwK5Ia2',
+				label: i18n.ts.themeForDarkMode,
+				keywords: ['dark', 'theme'],
+			},
+			{
+				id: 'ujvMfyzUr',
+				label: i18n.ts.setWallpaper,
+				keywords: ['wallpaper'],
+			},
+		],
+		label: i18n.ts.theme,
+		keywords: ['theme'],
+		path: '/settings/theme',
+		icon: 'ti ti-palette',
+	},
+	{
+		id: '6fFIRXUww',
+		children: [
+			{
+				id: 'nO7NnzqiC',
+				label: i18n.ts.notUseSound,
+				keywords: ['mute'],
+			},
+			{
+				id: 'oALW4ja7U',
+				label: i18n.ts.useSoundOnlyWhenActive,
+				keywords: ['active', 'mute'],
+			},
+			{
+				id: 'BbJK2SKT2',
+				label: i18n.ts.masterVolume,
+				keywords: ['volume', 'master'],
+			},
+		],
+		label: i18n.ts.sounds,
+		keywords: ['sounds'],
+		path: '/settings/sounds',
+		icon: 'ti ti-music',
+	},
+	{
+		id: '5BjnxMfYV',
+		children: [
+			{
+				id: '3UqdSCaFw',
+				children: [
+					{
+						id: '75QPEg57v',
+						label: i18n.ts.changePassword,
+						keywords: [],
+					},
+				],
+				label: i18n.ts.password,
+				keywords: ['password'],
+			},
+			{
+				id: '2fa',
+				children: [
+					{
+						id: 'qCXM0HtJ7',
+						label: i18n.ts.totp,
+						keywords: ['totp', 'app', i18n.ts.totpDescription],
+					},
+					{
+						id: '3g1RePuD9',
+						label: i18n.ts.securityKeyAndPasskey,
+						keywords: ['security', 'key', 'passkey'],
+					},
+					{
+						id: 'pFRud5u8k',
+						label: i18n.ts.passwordLessLogin,
+						keywords: ['password', 'less', 'key', 'passkey', 'login', 'signin', i18n.ts.passwordLessLoginDescription],
+					},
+				],
+				label: i18n.ts['2fa'],
+				keywords: ['2fa'],
+			},
+		],
+		label: i18n.ts.security,
+		keywords: ['security'],
+		path: '/settings/security',
+		icon: 'ti ti-lock',
+	},
+	{
+		id: 'w4L6myH61',
+		children: [
+			{
+				id: 'ru8DrOn3J',
+				label: i18n.ts._profile.changeBanner,
+				keywords: ['banner', 'change'],
+			},
+			{
+				id: 'CCnD8Apnu',
+				label: i18n.ts._profile.changeAvatar,
+				keywords: ['avatar', 'icon', 'change'],
+			},
+			{
+				id: 'yFEVCJxFX',
+				label: i18n.ts._profile.name,
+				keywords: ['name'],
+			},
+			{
+				id: '2O1S5reaB',
+				label: i18n.ts._profile.description,
+				keywords: ['description', 'bio'],
+			},
+			{
+				id: 'pWi4OLS8g',
+				label: i18n.ts.location,
+				keywords: ['location', 'locale'],
+			},
+			{
+				id: 'oLO5X6Wtw',
+				label: i18n.ts.birthday,
+				keywords: ['birthday', 'birthdate', 'age'],
+			},
+			{
+				id: 'm2trKwPgq',
+				label: i18n.ts.language,
+				keywords: ['language', 'locale'],
+			},
+			{
+				id: 'kfDZxCDp9',
+				label: i18n.ts._profile.metadataEdit,
+				keywords: ['metadata'],
+			},
+			{
+				id: 'uPt3MFymp',
+				label: i18n.ts._profile.followedMessage,
+				keywords: ['follow', 'message', i18n.ts._profile.followedMessageDescription],
+			},
+			{
+				id: 'wuGg0tBjw',
+				label: i18n.ts.reactionAcceptance,
+				keywords: ['reaction'],
+			},
+			{
+				id: 'EezPpmMnf',
+				children: [
+					{
+						id: 'f2cRLh8ad',
+						label: i18n.ts.flagAsCat,
+						keywords: ['cat'],
+					},
+					{
+						id: 'eVoViiF3h',
+						label: i18n.ts.flagAsBot,
+						keywords: ['bot'],
+					},
+				],
+				label: i18n.ts.advancedSettings,
+				keywords: [],
+			},
+		],
+		label: i18n.ts.profile,
+		keywords: ['profile'],
+		path: '/settings/profile',
+		icon: 'ti ti-user',
+	},
+	{
+		id: '2rp9ka5Ht',
+		children: [
+			{
+				id: 'qBUSKPxLW',
+				label: i18n.ts.makeFollowManuallyApprove,
+				keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo],
+			},
+			{
+				id: '3LZBlZCej',
+				label: i18n.ts.autoAcceptFollowed,
+				keywords: ['follow', 'auto', 'accept'],
+			},
+			{
+				id: '9gOp28wKG',
+				label: i18n.ts.makeReactionsPublic,
+				keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription],
+			},
+			{
+				id: 'CjAkqMhct',
+				label: i18n.ts.followingVisibility,
+				keywords: ['following', 'visibility'],
+			},
+			{
+				id: '4nEwI6LYt',
+				label: i18n.ts.followersVisibility,
+				keywords: ['follower', 'visibility'],
+			},
+			{
+				id: 'naMp37wTL',
+				label: i18n.ts.hideOnlineStatus,
+				keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription],
+			},
+			{
+				id: 'p0dCVR0UP',
+				label: i18n.ts.noCrawle,
+				keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription],
+			},
+			{
+				id: 'aceURmNPq',
+				label: i18n.ts.preventAiLearning,
+				keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription],
+			},
+			{
+				id: 'ahABA0j7u',
+				label: i18n.ts.makeExplorable,
+				keywords: ['explore', i18n.ts.makeExplorableDescription],
+			},
+			{
+				id: 'cyeDbLN8N',
+				children: [
+					{
+						id: 'xEYlOghao',
+						label: i18n.ts._accountSettings.requireSigninToViewContents,
+						keywords: ['login', 'signin'],
+					},
+					{
+						id: 'sMmYFCS60',
+						label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
+						keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
+					},
+					{
+						id: '2prkeWRSd',
+						label: i18n.ts._accountSettings.makeNotesHiddenBefore,
+						keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
+					},
+				],
+				label: i18n.ts.lockdown,
+				keywords: ['lockdown'],
+			},
+		],
+		label: i18n.ts.privacy,
+		keywords: ['privacy'],
+		path: '/settings/privacy',
+		icon: 'ti ti-lock-open',
+	},
+	{
+		id: '3yCAv0IsZ',
+		children: [
+			{
+				id: 'x1GWSQnPw',
+				label: i18n.ts.uiLanguage,
+				keywords: ['language'],
+			},
+			{
+				id: 'EOSa4rtt3',
+				label: i18n.ts.overridedDeviceKind,
+				keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'],
+			},
+			{
+				id: 'm9LhX8BG8',
+				label: i18n.ts.showFixedPostForm,
+				keywords: ['post', 'form', 'timeline'],
+			},
+			{
+				id: 'snyCQ5oKE',
+				label: i18n.ts.showFixedPostFormInChannel,
+				keywords: ['post', 'form', 'timeline', 'channel'],
+			},
+			{
+				id: '8j36S4Ev6',
+				label: i18n.ts.pinnedList,
+				keywords: ['pinned', 'list'],
+			},
+			{
+				id: 'CWpyT9vLK',
+				label: i18n.ts.enableQuickAddMfmFunction,
+				keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'],
+			},
+			{
+				id: '1yhown1Xc',
+				label: i18n.ts.rememberNoteVisibility,
+				keywords: ['remember', 'keep', 'note', 'visibility'],
+			},
+			{
+				id: 'wUeAI5QBV',
+				label: i18n.ts.defaultNoteVisibility,
+				keywords: ['default', 'note', 'visibility'],
+			},
+			{
+				id: '6kMj4HVOg',
+				children: [
+					{
+						id: 'DQIcvf64G',
+						label: i18n.ts.collapseRenotes,
+						keywords: ['renote', i18n.ts.collapseRenotesDescription],
+					},
+					{
+						id: 'igFN7RIUa',
+						label: i18n.ts.showNoteActionsOnlyHover,
+						keywords: ['hover', 'show', 'footer', 'action'],
+					},
+					{
+						id: '9uxocbLO0',
+						label: i18n.ts.showClipButtonInNoteFooter,
+						keywords: ['footer', 'action', 'clip', 'show'],
+					},
+					{
+						id: 'eaT1O1Fao',
+						label: i18n.ts.enableAdvancedMfm,
+						keywords: ['mfm', 'enable', 'show', 'advanced'],
+					},
+					{
+						id: 'omxZk3eET',
+						label: i18n.ts.showReactionsCount,
+						keywords: ['reaction', 'count', 'show'],
+					},
+					{
+						id: 'epvi2Nv2G',
+						label: i18n.ts.loadRawImages,
+						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'],
+					},
+				],
+				label: i18n.ts.note,
+				keywords: ['note'],
+			},
+			{
+				id: 'jb3HUeyrx',
+				children: [
+					{
+						id: 'ykifk3NHS',
+						label: i18n.ts.useGroupedNotifications,
+						keywords: ['group'],
+					},
+				],
+				label: i18n.ts.notifications,
+				keywords: ['notification'],
+			},
+			{
+				id: 'abEAdSpYY',
+				children: [
+					{
+						id: 'lBbtAg0Hm',
+						label: i18n.ts.openImageInNewTab,
+						keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'],
+					},
+					{
+						id: 'E9whefUtX',
+						label: i18n.ts.useReactionPickerForContextMenu,
+						keywords: ['reaction', 'picker', 'contextmenu', 'open'],
+					},
+					{
+						id: 'iQaBbJBva',
+						label: i18n.ts.enableInfiniteScroll,
+						keywords: ['load', 'auto', 'more'],
+					},
+					{
+						id: 'hgEVGgJa1',
+						label: i18n.ts.disableStreamingTimeline,
+						keywords: ['disable', 'streaming', 'timeline'],
+					},
+					{
+						id: 'yxehrHZ6x',
+						label: i18n.ts.alwaysConfirmFollow,
+						keywords: ['follow', 'confirm', 'always'],
+					},
+					{
+						id: 'DdoFLaSG8',
+						label: i18n.ts.confirmWhenRevealingSensitiveMedia,
+						keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'],
+					},
+					{
+						id: 'uIMCIK7kG',
+						label: i18n.ts.confirmOnReact,
+						keywords: ['reaction', 'confirm'],
+					},
+					{
+						id: 'zvM13vl26',
+						label: i18n.ts.keepCw,
+						keywords: ['remember', 'keep', 'note', 'cw'],
+					},
+					{
+						id: 'm75VEWI3S',
+						label: i18n.ts.whenServerDisconnected,
+						keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'],
+					},
+					{
+						id: 'bLO9vCyKW',
+						label: i18n.ts.numberOfPageCache,
+						keywords: ['cache', 'page'],
+					},
+					{
+						id: 'iQ7Er89l5',
+						label: i18n.ts.dataSaver,
+						keywords: ['datasaver'],
+					},
+				],
+				label: i18n.ts.behavior,
+				keywords: ['behavior'],
+			},
+			{
+				id: 'C2WYcVM1d',
+				children: [
+					{
+						id: 'Cu7ErCM7C',
+						label: i18n.ts.forceShowAds,
+						keywords: ['ad', 'show'],
+					},
+					{
+						id: 'BBxwy4F6E',
+						label: i18n.ts.hemisphere,
+						keywords: [],
+					},
+					{
+						id: '9YdUwDC8d',
+						label: i18n.ts.additionalEmojiDictionary,
+						keywords: ['emoji', 'dictionary', 'additional', 'extra'],
+					},
+				],
+				label: i18n.ts.other,
+				keywords: [],
+			},
+		],
+		label: i18n.ts.preferences,
+		keywords: ['general', 'preferences'],
+		path: '/settings/preferences',
+		icon: 'ti ti-adjustments',
+	},
+	{
+		id: 'mwkwtw83Y',
+		label: i18n.ts.plugins,
+		keywords: ['plugin'],
+		path: '/settings/plugin',
+		icon: 'ti ti-plug',
+	},
+	{
+		id: 'F1uK9ssiY',
+		children: [
+			{
+				id: 'msAcN6u3S',
+				label: i18n.ts.accountInfo,
+				keywords: ['account', 'info'],
+			},
+			{
+				id: 'ts8DgdnZV',
+				label: i18n.ts.accountMigration,
+				keywords: ['account', 'move', 'migration'],
+			},
+			{
+				id: '4BG7nBECm',
+				label: i18n.ts.closeAccount,
+				keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete],
+			},
+			{
+				id: '2qI6ruPgi',
+				label: i18n.ts.experimentalFeatures,
+				keywords: ['experimental', 'feature', 'flags'],
+			},
+			{
+				id: 'cIeaax47o',
+				label: i18n.ts.developer,
+				keywords: ['developer', 'mode', 'debug'],
+			},
+		],
+		label: i18n.ts.other,
+		keywords: ['other'],
+		path: '/settings/other',
+		icon: 'ti ti-dots',
+	},
+	{
+		id: '3icEvyv2D',
+		children: [
+			{
+				id: 'Tyt3gZTy',
+				children: [
+					{
+						id: '9b7ZURyAt',
+						label: i18n.ts.showMutedWord,
+						keywords: ['show'],
+					},
+				],
+				label: i18n.ts.wordMute,
+				keywords: ['note', 'word', 'soft', 'mute', 'hide'],
+			},
+			{
+				id: 'kdMk41II0',
+				label: i18n.ts.hardWordMute,
+				keywords: ['note', 'word', 'hard', 'mute', 'hide'],
+			},
+			{
+				id: 'mjORQamAK',
+				label: i18n.ts.instanceMute,
+				keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'],
+			},
+			{
+				id: '1ZT7S9FZd',
+				label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`,
+				keywords: ['renote', 'mute', 'hide', 'user'],
+			},
+			{
+				id: 'ANrPit3kQ',
+				label: i18n.ts.mutedUsers,
+				keywords: ['note', 'mute', 'hide', 'user'],
+			},
+			{
+				id: 'bPAE4lfno',
+				label: i18n.ts.blockedUsers,
+				keywords: ['block', 'user'],
+			},
+		],
+		label: i18n.ts.muteAndBlock,
+		keywords: ['mute', 'block'],
+		path: '/settings/mute-block',
+		icon: 'ti ti-ban',
+	},
+	{
+		id: 'qE2vLlMkF',
+		children: [
+			{
+				id: 'hPPEzjvZC',
+				label: i18n.ts._exportOrImport.allNotes,
+				keywords: ['notes'],
+			},
+			{
+				id: 'AFaeHsCUB',
+				label: i18n.ts._exportOrImport.favoritedNotes,
+				keywords: ['favorite', 'notes'],
+			},
+			{
+				id: 'xyCPmQiRo',
+				label: i18n.ts._exportOrImport.clips,
+				keywords: ['clip', 'notes'],
+			},
+			{
+				id: 'Ch7hWAGUy',
+				label: i18n.ts._exportOrImport.followingList,
+				keywords: ['following', 'users'],
+			},
+			{
+				id: 'AwPgFboEx',
+				label: i18n.ts._exportOrImport.userLists,
+				keywords: ['user', 'lists'],
+			},
+			{
+				id: 'nporiHshC',
+				label: i18n.ts._exportOrImport.muteList,
+				keywords: ['mute', 'users'],
+			},
+			{
+				id: 'BsCzR7vNw',
+				label: i18n.ts._exportOrImport.blockingList,
+				keywords: ['block', 'users'],
+			},
+			{
+				id: 'dvf4IgYrQ',
+				label: i18n.ts.antennas,
+				keywords: ['antennas'],
+			},
+		],
+		label: i18n.ts.importAndExport,
+		keywords: ['import', 'export', 'data'],
+		path: '/settings/import-export',
+		icon: 'ti ti-package',
+	},
+	{
+		id: '3Tcxw4Fwl',
+		children: [
+			{
+				id: 'iIai9O65I',
+				label: i18n.ts.emailAddress,
+				keywords: ['email', 'address'],
+			},
+			{
+				id: 'i6cC6oi0m',
+				label: i18n.ts.receiveAnnouncementFromInstance,
+				keywords: ['announcement', 'email'],
+			},
+			{
+				id: 'C1YTinP11',
+				label: i18n.ts.emailNotification,
+				keywords: ['notification', 'email'],
+			},
+		],
+		label: i18n.ts.email,
+		keywords: ['email'],
+		path: '/settings/email',
+		icon: 'ti ti-mail',
+	},
+	{
+		id: 'tnYoppRiv',
+		children: [
+			{
+				id: 'ncIq6TAR2',
+				label: i18n.ts.usageAmount,
+				keywords: ['capacity', 'usage'],
+			},
+			{
+				id: '2c4CQSvSr',
+				label: i18n.ts.statistics,
+				keywords: ['statistics', 'usage'],
+			},
+			{
+				id: 'pepHELHMt',
+				label: i18n.ts.uploadFolder,
+				keywords: ['default', 'upload', 'folder'],
+			},
+			{
+				id: 'xqOWrABxV',
+				label: i18n.ts.keepOriginalUploading,
+				keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription],
+			},
+			{
+				id: 'D8HUTGWE1',
+				label: i18n.ts.keepOriginalFilename,
+				keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription],
+			},
+			{
+				id: '6xAvsWSZi',
+				label: i18n.ts.alwaysMarkSensitive,
+				keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'],
+			},
+			{
+				id: 'csNNPF1KX',
+				label: i18n.ts.enableAutoSensitive,
+				keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription],
+			},
+		],
+		label: i18n.ts.drive,
+		keywords: ['drive'],
+		path: '/settings/drive',
+		icon: 'ti ti-cloud',
+	},
+	{
+		id: 'gtaOSdIJB',
+		label: i18n.ts.avatarDecorations,
+		keywords: ['avatar', 'icon', 'decoration'],
+		path: '/settings/avatar-decoration',
+		icon: 'ti ti-sparkles',
+	},
+	{
+		id: 'AqPvMgn3A',
+		children: [
+			{
+				id: 'j5gTtuMWP',
+				label: i18n.ts.useBlurEffect,
+				keywords: ['blur'],
+			},
+			{
+				id: 'C05WQNSIJ',
+				label: i18n.ts.useBlurEffectForModal,
+				keywords: ['blur', 'modal'],
+			},
+			{
+				id: 'snVKNr7Bw',
+				label: i18n.ts.highlightSensitiveMedia,
+				keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'],
+			},
+			{
+				id: 'DsS2CwjYE',
+				label: i18n.ts.squareAvatars,
+				keywords: ['avatar', 'icon', 'square'],
+			},
+			{
+				id: 'xCcTDl651',
+				label: i18n.ts.showAvatarDecorations,
+				keywords: ['avatar', 'icon', 'decoration', 'show'],
+			},
+			{
+				id: '3dHw723VD',
+				label: i18n.ts.showGapBetweenNotesInTimeline,
+				keywords: ['note', 'timeline', 'gap'],
+			},
+			{
+				id: 'AWi72xbrl',
+				label: i18n.ts.seasonalScreenEffect,
+				keywords: ['effect', 'show'],
+			},
+			{
+				id: 'Ces8FsJws',
+				label: i18n.ts.menuStyle,
+				keywords: ['menu', 'style', 'popup', 'drawer'],
+			},
+			{
+				id: 'wDr9xSXCv',
+				label: i18n.ts.emojiStyle,
+				keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'],
+			},
+			{
+				id: 'vFB0pLzck',
+				label: i18n.ts.fontSize,
+				keywords: ['font', 'size'],
+			},
+			{
+				id: '23BhvYXPC',
+				label: i18n.ts.useSystemFont,
+				keywords: ['font', 'system', 'native'],
+			},
+			{
+				id: 'EeNLndAOa',
+				children: [
+					{
+						id: 'rAAPoaodS',
+						label: i18n.ts.reactionsDisplaySize,
+						keywords: ['reaction', 'size', 'scale', 'display'],
+					},
+					{
+						id: 'qTLAvNWsc',
+						label: i18n.ts.limitWidthOfReaction,
+						keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'],
+					},
+					{
+						id: '2lWgzAm13',
+						label: i18n.ts.mediaListWithOneImageAppearance,
+						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'],
+					},
+					{
+						id: 'EU7HbxOR5',
+						label: i18n.ts.instanceTicker,
+						keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'],
+					},
+					{
+						id: 'AEtM0FAp1',
+						label: i18n.ts.displayOfSensitiveMedia,
+						keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'],
+					},
+				],
+				label: i18n.ts.displayOfNote,
+				keywords: ['note', 'display'],
+			},
+			{
+				id: 'A1FMC2Zon',
+				children: [
+					{
+						id: 'CB37G5ZDo',
+						label: i18n.ts.position,
+						keywords: ['position'],
+					},
+					{
+						id: 'gGS2i19hS',
+						label: i18n.ts.stackAxis,
+						keywords: ['stack', 'axis', 'direction'],
+					},
+				],
+				label: i18n.ts.notificationDisplay,
+				keywords: ['notification', 'display'],
+			},
+		],
+		label: i18n.ts.appearance,
+		keywords: ['appearance'],
+		path: '/settings/appearance',
+		icon: 'ti ti-device-desktop',
+	},
+	{
+		id: 'f08Mi1Uwn',
+		children: [
+			{
+				id: '7ov7ceoij',
+				label: i18n.ts.reduceUiAnimation,
+				keywords: ['animation', 'motion', 'reduce'],
+			},
+			{
+				id: 'cXr3tFdpa',
+				label: i18n.ts.disableShowingAnimatedImages,
+				keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'],
+			},
+			{
+				id: 'Ok1UBwtP',
+				label: i18n.ts.enableAnimatedMfm,
+				keywords: ['mfm', 'enable', 'show', 'animated'],
+			},
+			{
+				id: 'yPEpJigqY',
+				label: i18n.ts.enableHorizontalSwipe,
+				keywords: ['swipe', 'horizontal', 'tab'],
+			},
+			{
+				id: 'h7iZtdTU3',
+				label: i18n.ts.keepScreenOn,
+				keywords: ['keep', 'screen', 'display', 'on'],
+			},
+			{
+				id: 'gP1BY3PDy',
+				label: i18n.ts.useNativeUIForVideoAudioPlayer,
+				keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
+			},
+			{
+				id: 'jnMK3M6rs',
+				label: i18n.ts._contextMenu.title,
+				keywords: ['contextmenu', 'system', 'native'],
+			},
+		],
+		label: i18n.ts.accessibility,
+		keywords: ['accessibility'],
+		path: '/settings/accessibility',
+		icon: 'ti ti-accessible',
+	},
+] as const;
+
+export type SearchIndex = typeof searchIndexes;
diff --git a/packages/frontend/src/utility/cache.ts b/packages/frontend/src/utility/cache.ts
new file mode 100644
index 0000000000..0fbdf34d5d
--- /dev/null
+++ b/packages/frontend/src/utility/cache.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+
+export class Cache<T> {
+	private cachedAt: number | null = null;
+	public value = ref<T | undefined>();
+	private lifetime: number;
+	private fetcher: () => Promise<T>;
+
+	constructor(lifetime: Cache<never>['lifetime'], fetcher: () => Promise<T>) {
+		this.lifetime = lifetime;
+		this.fetcher = fetcher;
+	}
+
+	public set(value: T): void {
+		this.cachedAt = Date.now();
+		this.value.value = value;
+	}
+
+	private get(): T | undefined {
+		if (this.cachedAt == null) return undefined;
+		if ((Date.now() - this.cachedAt) > this.lifetime) {
+			this.value.value = undefined;
+			this.cachedAt = null;
+			return undefined;
+		}
+		return this.value.value;
+	}
+
+	public delete() {
+		this.cachedAt = null;
+	}
+
+	/**
+	 * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
+	 */
+	public async fetch(): Promise<T> {
+		const cachedValue = this.get();
+		if (cachedValue !== undefined) {
+			// Cache HIT
+			return cachedValue;
+		}
+
+		// Cache MISS
+		const value = await this.fetcher();
+		this.set(value);
+		return value;
+	}
+}
diff --git a/packages/frontend/src/utility/chart-legend.ts b/packages/frontend/src/utility/chart-legend.ts
new file mode 100644
index 0000000000..e701d18dd2
--- /dev/null
+++ b/packages/frontend/src/utility/chart-legend.ts
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Plugin } from 'chart.js';
+import MkChartLegend from '@/components/MkChartLegend.vue';
+
+export const chartLegend = (legend: InstanceType<typeof MkChartLegend>) => ({
+	id: 'htmlLegend',
+	afterUpdate(chart, args, options) {
+		// Reuse the built-in legendItems generator
+		const items = chart.options.plugins.legend.labels.generateLabels(chart);
+
+		legend.update(chart, items);
+	},
+}) as Plugin;
diff --git a/packages/frontend/src/utility/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts
new file mode 100644
index 0000000000..465ca591c6
--- /dev/null
+++ b/packages/frontend/src/utility/chart-vline.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Plugin } from 'chart.js';
+
+export const chartVLine = (vLineColor: string) => ({
+	id: 'vLine',
+	beforeDraw(chart, args, options) {
+		if (chart.tooltip?._active?.length) {
+			const ctx = chart.ctx;
+			const xs = chart.tooltip._active.map(a => a.element.x);
+			const x = xs.reduce((a, b) => a + b, 0) / xs.length;
+			const topY = chart.scales.y.top;
+			const bottomY = chart.scales.y.bottom;
+
+			ctx.save();
+			ctx.beginPath();
+			ctx.moveTo(x, bottomY);
+			ctx.lineTo(x, topY);
+			ctx.lineWidth = 1;
+			ctx.strokeStyle = vLineColor;
+			ctx.stroke();
+			ctx.restore();
+		}
+	},
+}) as Plugin;
diff --git a/packages/frontend/src/utility/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts
new file mode 100644
index 0000000000..ed86529d5b
--- /dev/null
+++ b/packages/frontend/src/utility/check-permissions.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { instance } from '@/instance.js';
+import { $i } from '@/account.js';
+
+export const notesSearchAvailable = (
+	// FIXME: instance.policies would be null in Vitest
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	($i == null && instance.policies != null && instance.policies.canSearchNotes) ||
+	($i != null && $i.policies.canSearchNotes) ||
+	false
+) as boolean;
+
+export const canSearchNonLocalNotes = (
+	instance.noteSearchableScope === 'global'
+);
diff --git a/packages/frontend/src/utility/check-reaction-permissions.ts b/packages/frontend/src/utility/check-reaction-permissions.ts
new file mode 100644
index 0000000000..281ea2520e
--- /dev/null
+++ b/packages/frontend/src/utility/check-reaction-permissions.ts
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import type { UnicodeEmojiDef } from '@@/js/emojilist.js';
+
+export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
+	if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする;
+	if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
+
+	const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
+	return !(emoji.localOnly && note.user.host !== me.host)
+      && !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
+      && (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id)));
+}
diff --git a/packages/frontend/src/utility/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts
new file mode 100644
index 0000000000..98fea1bced
--- /dev/null
+++ b/packages/frontend/src/utility/check-word-mute.ts
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import * as Misskey from 'misskey-js';
+
+export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
+	// 自分自身
+	if (me && (note.userId === me.id)) return false;
+
+	if (mutedWords.length > 0) {
+		const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
+
+		if (text === '') return false;
+
+		const matched = mutedWords.filter(filter => {
+			if (Array.isArray(filter)) {
+				// Clean up
+				const filteredFilter = filter.filter(keyword => keyword !== '');
+				if (filteredFilter.length === 0) return false;
+
+				return filteredFilter.every(keyword => text.includes(keyword));
+			} else {
+				// represents RegExp
+				const regexp = filter.match(/^\/(.+)\/(.*)$/);
+
+				// This should never happen due to input sanitisation.
+				if (!regexp) return false;
+
+				try {
+					return new RegExp(regexp[1], regexp[2]).test(text);
+				} catch (err) {
+					// This should never happen due to input sanitisation.
+					return false;
+				}
+			}
+		});
+
+		if (matched.length > 0) return matched;
+	}
+
+	return false;
+}
diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts
new file mode 100644
index 0000000000..b6ae254727
--- /dev/null
+++ b/packages/frontend/src/utility/clear-cache.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { unisonReload } from '@/utility/unison-reload.js';
+import * as os from '@/os.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { fetchCustomEmojis } from '@/custom-emojis.js';
+import { fetchInstance } from '@/instance.js';
+
+export async function clearCache() {
+	os.waiting();
+	miLocalStorage.removeItem('instance');
+	miLocalStorage.removeItem('instanceCachedAt');
+	miLocalStorage.removeItem('locale');
+	miLocalStorage.removeItem('localeVersion');
+	miLocalStorage.removeItem('theme');
+	miLocalStorage.removeItem('emojis');
+	miLocalStorage.removeItem('lastEmojisFetchedAt');
+	await fetchInstance(true);
+	await fetchCustomEmojis(true);
+	unisonReload();
+}
diff --git a/packages/frontend/src/utility/clicker-game.ts b/packages/frontend/src/utility/clicker-game.ts
new file mode 100644
index 0000000000..0544be7757
--- /dev/null
+++ b/packages/frontend/src/utility/clicker-game.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, computed } from 'vue';
+import { misskeyApi } from '@/utility/misskey-api.js';
+
+type SaveData = {
+	gameVersion: number;
+	cookies: number;
+	totalCookies: number;
+	totalHandmadeCookies: number;
+	clicked: number;
+	achievements: any[];
+	facilities: any[];
+};
+
+export const saveData = ref<SaveData>();
+export const ready = computed(() => saveData.value != null);
+
+let prev = '';
+
+export async function load() {
+	try {
+		saveData.value = await misskeyApi('i/registry/get', {
+			scope: ['clickerGame'],
+			key: 'saveData',
+		});
+	} catch (err) {
+		if (err.code === 'NO_SUCH_KEY') {
+			saveData.value = {
+				gameVersion: 2,
+				cookies: 0,
+				totalCookies: 0,
+				totalHandmadeCookies: 0,
+				clicked: 0,
+				achievements: [],
+				facilities: [],
+			};
+			save();
+			return;
+		}
+		throw err;
+	}
+
+	// migration
+	if (saveData.value.gameVersion === 1) {
+		saveData.value = {
+			gameVersion: 2,
+			cookies: saveData.value.cookies,
+			totalCookies: saveData.value.cookies,
+			totalHandmadeCookies: saveData.value.cookies,
+			clicked: saveData.value.clicked,
+			achievements: [],
+			facilities: [],
+		};
+		save();
+	}
+}
+
+export async function save() {
+	const current = JSON.stringify(saveData.value);
+	if (current === prev) return;
+
+	await misskeyApi('i/registry/set', {
+		scope: ['clickerGame'],
+		key: 'saveData',
+		value: saveData.value,
+	});
+
+	prev = current;
+}
diff --git a/packages/frontend/src/utility/clone.ts b/packages/frontend/src/utility/clone.ts
new file mode 100644
index 0000000000..ea8eea14b5
--- /dev/null
+++ b/packages/frontend/src/utility/clone.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// structredCloneが遅いため
+// SEE: http://var.blog.jp/archives/86038606.html
+// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
+// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
+
+export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
+
+export function deepClone<T extends Cloneable>(x: T): T {
+	if (typeof x === 'object') {
+		if (x === null) return x;
+		if (Array.isArray(x)) return x.map(deepClone) as T;
+		const obj = {} as Record<string | number | symbol, Cloneable>;
+		for (const [k, v] of Object.entries(x)) {
+			obj[k] = v === undefined ? undefined : deepClone(v);
+		}
+		return obj as T;
+	} else {
+		return x;
+	}
+}
diff --git a/packages/frontend/src/utility/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts
new file mode 100644
index 0000000000..4f2aff9d4c
--- /dev/null
+++ b/packages/frontend/src/utility/code-highlighter.ts
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { createHighlighterCore } from 'shiki/core';
+import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
+import darkPlus from 'shiki/themes/dark-plus.mjs';
+import { bundledThemesInfo } from 'shiki/themes';
+import { bundledLanguagesInfo } from 'shiki/langs';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
+import defaultLightTheme from '@@/themes/l-light.json5';
+import defaultDarkTheme from '@@/themes/d-green-lime.json5';
+import { unique } from './array.js';
+import { deepClone } from './clone.js';
+import { deepMerge } from './merge.js';
+import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
+import { prefer } from '@/preferences.js';
+
+let _highlighter: HighlighterCore | null = null;
+
+export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
+export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
+export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> {
+	const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme);
+
+	if (theme.base) {
+		const base = [lightTheme, darkTheme].find(x => x.id === theme.base);
+		if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter);
+	}
+
+	if (theme.codeHighlighter) {
+		let _res: ThemeRegistration = {};
+		if (theme.codeHighlighter.base === '_none_') {
+			_res = deepClone(theme.codeHighlighter.overrides);
+		} else {
+			const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus;
+			_res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base);
+		}
+		if (_res.name == null) {
+			_res.name = theme.id;
+		}
+		_res.type = mode;
+
+		if (getName) {
+			return _res.name;
+		}
+		return _res;
+	}
+
+	if (getName) {
+		return 'dark-plus';
+	}
+	return darkPlus;
+}
+
+export async function getHighlighter(): Promise<HighlighterCore> {
+	if (!_highlighter) {
+		return await initHighlighter();
+	}
+	return _highlighter;
+}
+
+async function initHighlighter() {
+	// テーマの重複を消す
+	const themes = unique([
+		darkPlus,
+		...(await Promise.all([getTheme('light'), getTheme('dark')])),
+	]);
+
+	const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
+	const highlighter = await createHighlighterCore({
+		engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')),
+		themes,
+		langs: [
+			...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
+			async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
+		],
+	});
+
+	// TODO
+	//watch('lightTheme', async () => {
+	//	const newTheme = await getTheme('light');
+	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
+	//		highlighter.loadTheme(newTheme);
+	//	}
+	//});
+	//watch('darkTheme', async () => {
+	//	const newTheme = await getTheme('dark');
+	//	if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) {
+	//		highlighter.loadTheme(newTheme);
+	//	}
+	//});
+
+	_highlighter = highlighter;
+
+	return highlighter;
+}
diff --git a/packages/frontend/src/utility/collect-page-vars.ts b/packages/frontend/src/utility/collect-page-vars.ts
new file mode 100644
index 0000000000..5096c0669e
--- /dev/null
+++ b/packages/frontend/src/utility/collect-page-vars.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+interface StringPageVar {
+	name: string,
+	type: 'string',
+	value: string
+}
+
+interface NumberPageVar {
+	name: string,
+	type: 'number',
+	value: number
+}
+
+interface BooleanPageVar {
+	name: string,
+	type: 'boolean',
+	value: boolean
+}
+
+type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
+
+export function collectPageVars(content): PageVar[] {
+	const pageVars: PageVar[] = [];
+	const collect = (xs: any[]): void => {
+		for (const x of xs) {
+			if (x.type === 'textInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.type === 'textareaInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.type === 'numberInput') {
+				pageVars.push({
+					name: x.name,
+					type: 'number',
+					value: x.default || 0,
+				});
+			} else if (x.type === 'switch') {
+				pageVars.push({
+					name: x.name,
+					type: 'boolean',
+					value: x.default || false,
+				});
+			} else if (x.type === 'counter') {
+				pageVars.push({
+					name: x.name,
+					type: 'number',
+					value: 0,
+				});
+			} else if (x.type === 'radioButton') {
+				pageVars.push({
+					name: x.name,
+					type: 'string',
+					value: x.default || '',
+				});
+			} else if (x.children) {
+				collect(x.children);
+			}
+		}
+	};
+	collect(content);
+	return pageVars;
+}
diff --git a/packages/frontend/src/utility/color.ts b/packages/frontend/src/utility/color.ts
new file mode 100644
index 0000000000..a11255ffd1
--- /dev/null
+++ b/packages/frontend/src/utility/color.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const alpha = (hex: string, a: number): string => {
+	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+	const r = parseInt(result[1], 16);
+	const g = parseInt(result[2], 16);
+	const b = parseInt(result[3], 16);
+	return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
diff --git a/packages/frontend/src/utility/confetti.ts b/packages/frontend/src/utility/confetti.ts
new file mode 100644
index 0000000000..8e53a6ceeb
--- /dev/null
+++ b/packages/frontend/src/utility/confetti.ts
@@ -0,0 +1,30 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import _confetti from 'canvas-confetti';
+import * as os from '@/os.js';
+
+export function confetti(options: { duration?: number; } = {}) {
+	const duration = options.duration ?? 1000 * 4;
+	const animationEnd = Date.now() + duration;
+	const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: os.claimZIndex('high') };
+
+	function randomInRange(min, max) {
+		return Math.random() * (max - min) + min;
+	}
+
+	const interval = setInterval(() => {
+		const timeLeft = animationEnd - Date.now();
+
+		if (timeLeft <= 0) {
+			return clearInterval(interval);
+		}
+
+		const particleCount = 50 * (timeLeft / duration);
+		// since particles fall down, start a bit higher than random
+		_confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }));
+		_confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }));
+	}, 250);
+}
diff --git a/packages/frontend/src/utility/contains.ts b/packages/frontend/src/utility/contains.ts
new file mode 100644
index 0000000000..6137c06e85
--- /dev/null
+++ b/packages/frontend/src/utility/contains.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export default (parent, child, checkSame = true) => {
+	if (checkSame && parent === child) return true;
+	let node = child.parentNode;
+	while (node) {
+		if (node === parent) return true;
+		node = node.parentNode;
+	}
+	return false;
+};
diff --git a/packages/frontend/src/utility/copy-to-clipboard.ts b/packages/frontend/src/utility/copy-to-clipboard.ts
new file mode 100644
index 0000000000..7e0bb25606
--- /dev/null
+++ b/packages/frontend/src/utility/copy-to-clipboard.ts
@@ -0,0 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * Clipboardに値をコピー(TODO: 文字列以外も対応)
+ */
+export function copyToClipboard(input: string | null) {
+	if (input) navigator.clipboard.writeText(input);
+};
diff --git a/packages/frontend/src/utility/device-kind.ts b/packages/frontend/src/utility/device-kind.ts
new file mode 100644
index 0000000000..7aadb617ca
--- /dev/null
+++ b/packages/frontend/src/utility/device-kind.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type DeviceKind = 'smartphone' | 'tablet' | 'desktop';
+
+const ua = navigator.userAgent.toLowerCase();
+const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
+const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
+
+export const DEFAULT_DEVICE_KIND: DeviceKind = (
+	isSmartphone
+		? 'smartphone'
+		: isTablet
+			? 'tablet'
+			: 'desktop'
+);
+
+export let deviceKind: DeviceKind = DEFAULT_DEVICE_KIND;
+
+export function updateDeviceKind(kind: DeviceKind | null) {
+	deviceKind = kind ?? DEFAULT_DEVICE_KIND;
+}
diff --git a/packages/frontend/src/utility/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts
new file mode 100644
index 0000000000..7ff5863243
--- /dev/null
+++ b/packages/frontend/src/utility/emoji-picker.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent, ref } from 'vue';
+import type { Ref } from 'vue';
+import { popup } from '@/os.js';
+import { store } from '@/store.js';
+
+/**
+ * 絵文字ピッカーを表示する。
+ * 類似の機能として{@link ReactionPicker}が存在しているが、この機能とは動きが異なる。
+ * 投稿フォームなどで絵文字を選択する時など、絵文字ピックアップ後でもダイアログが消えずに残り、
+ * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。
+ */
+class EmojiPicker {
+	private src: Ref<HTMLElement | null> = ref(null);
+	private manualShowing = ref(false);
+	private onChosen?: (emoji: string) => void;
+	private onClosed?: () => void;
+
+	constructor() {
+		// nop
+	}
+
+	public async init() {
+		const emojisRef = store.reactiveState.pinnedEmojis;
+		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+			src: this.src,
+			pinnedEmojis: emojisRef,
+			asReactionPicker: false,
+			manualShowing: this.manualShowing,
+			choseAndClose: false,
+		}, {
+			done: emoji => {
+				if (this.onChosen) this.onChosen(emoji);
+			},
+			close: () => {
+				this.manualShowing.value = false;
+			},
+			closed: () => {
+				this.src.value = null;
+				if (this.onClosed) this.onClosed();
+			},
+		});
+	}
+
+	public show(
+		src: HTMLElement,
+		onChosen?: EmojiPicker['onChosen'],
+		onClosed?: EmojiPicker['onClosed'],
+	) {
+		this.src.value = src;
+		this.manualShowing.value = true;
+		this.onChosen = onChosen;
+		this.onClosed = onClosed;
+	}
+}
+
+export const emojiPicker = new EmojiPicker();
diff --git a/packages/frontend/src/utility/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts
new file mode 100644
index 0000000000..d518562053
--- /dev/null
+++ b/packages/frontend/src/utility/extract-mentions.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// test is located in test/extract-mentions
+
+import * as mfm from 'mfm-js';
+
+export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
+	// TODO: 重複を削除
+	const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
+	const mentions = mentionNodes.map(x => x.props);
+
+	return mentions;
+}
diff --git a/packages/frontend/src/utility/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts
new file mode 100644
index 0000000000..570823d5b5
--- /dev/null
+++ b/packages/frontend/src/utility/extract-url-from-mfm.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as mfm from 'mfm-js';
+import { unique } from '@/utility/array.js';
+
+// unique without hash
+// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
+const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
+
+export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
+	const urlNodes = mfm.extract(nodes, (node) => {
+		return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
+	});
+	const urls: string[] = unique(urlNodes.map(x => x.props.url));
+
+	return urls.reduce((array, url) => {
+		const urlWithoutHash = removeHash(url);
+		if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
+		return array;
+	}, [] as string[]);
+}
diff --git a/packages/frontend/src/utility/file-drop.ts b/packages/frontend/src/utility/file-drop.ts
new file mode 100644
index 0000000000..4259fe25e9
--- /dev/null
+++ b/packages/frontend/src/utility/file-drop.ts
@@ -0,0 +1,121 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type DroppedItem = DroppedFile | DroppedDirectory;
+
+export type DroppedFile = {
+	isFile: true;
+	path: string;
+	file: File;
+};
+
+export type DroppedDirectory = {
+	isFile: false;
+	path: string;
+	children: DroppedItem[];
+};
+
+export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> {
+	const dropItems = ev.dataTransfer?.items;
+	if (!dropItems || dropItems.length === 0) {
+		return [];
+	}
+
+	const apiTestItem = dropItems[0];
+	if ('webkitGetAsEntry' in apiTestItem) {
+		return readDataTransferItems(dropItems);
+	} else {
+		// webkitGetAsEntryに対応していない場合はfilesから取得する（ディレクトリのサポートは出来ない）
+		const dropFiles = ev.dataTransfer.files;
+		if (dropFiles.length === 0) {
+			return [];
+		}
+
+		const droppedFiles = Array.of<DroppedFile>();
+		for (let i = 0; i < dropFiles.length; i++) {
+			const file = dropFiles.item(i);
+			if (file) {
+				droppedFiles.push({
+					isFile: true,
+					path: file.name,
+					file,
+				});
+			}
+		}
+
+		return droppedFiles;
+	}
+}
+
+/**
+ * ドラッグ＆ドロップされたファイルのリストからディレクトリ構造とファイルへの参照（{@link File}）を取得する。
+ */
+export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
+	async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
+		if (entry.isFile) {
+			return {
+				isFile: true,
+				path: entry.fullPath,
+				file: await readFile(entry as FileSystemFileEntry),
+			};
+		} else {
+			return {
+				isFile: false,
+				path: entry.fullPath,
+				children: await readDirectory(entry as FileSystemDirectoryEntry),
+			};
+		}
+	}
+
+	function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
+		return new Promise((resolve, reject) => {
+			fileSystemFileEntry.file(resolve, reject);
+		});
+	}
+
+	function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
+		return new Promise(async (resolve) => {
+			const allEntries = Array.of<FileSystemEntry>();
+			const reader = fileSystemDirectoryEntry.createReader();
+			while (true) {
+				const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej));
+				if (entries.length === 0) {
+					break;
+				}
+				allEntries.push(...entries);
+			}
+
+			resolve(await Promise.all(allEntries.map(readEntry)));
+		});
+	}
+
+	// 扱いにくいので配列に変換
+	const items = Array.of<DataTransferItem>();
+	for (let i = 0; i < itemList.length; i++) {
+		items.push(itemList[i]);
+	}
+
+	return Promise.all(
+		items
+			.map(it => it.webkitGetAsEntry())
+			.filter(it => it)
+			.map(it => readEntry(it!)),
+	);
+}
+
+/**
+ * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。
+ */
+export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] {
+	const result = Array.of<DroppedFile>();
+	for (const item of items) {
+		if (item.isFile) {
+			result.push(item);
+		} else {
+			result.push(...flattenDroppedFiles(item.children));
+		}
+	}
+	return result;
+}
diff --git a/packages/frontend/src/utility/focus-trap.ts b/packages/frontend/src/utility/focus-trap.ts
new file mode 100644
index 0000000000..fd17fa38a0
--- /dev/null
+++ b/packages/frontend/src/utility/focus-trap.ts
@@ -0,0 +1,134 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
+
+const focusTrapElements = new Set<HTMLElement>();
+const ignoreElements = [
+	'script',
+	'style',
+];
+
+function containsFocusTrappedElements(el: HTMLElement): boolean {
+	return Array.from(focusTrapElements).some((focusTrapElement) => {
+		return el.contains(focusTrapElement);
+	});
+}
+
+function getZIndex(el: HTMLElement): number {
+	const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10);
+	if (isNaN(zIndex)) {
+		return 0;
+	}
+	return zIndex;
+}
+
+function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null {
+	let highestZIndexElement: HTMLElement | null = null;
+	let highestZIndex = -Infinity;
+
+	focusTrapElements.forEach((el) => {
+		const zIndex = getZIndex(el);
+		if (zIndex > highestZIndex) {
+			highestZIndex = zIndex;
+			highestZIndexElement = el;
+		}
+	});
+
+	return highestZIndexElement == null ? null : {
+		el: highestZIndexElement,
+		zIndex: highestZIndex,
+	};
+}
+
+function releaseFocusTrap(el: HTMLElement): void {
+	focusTrapElements.delete(el);
+	if (el.inert === true) {
+		el.inert = false;
+	}
+
+	const highestZIndexElement = getHighestZIndexElement();
+
+	if (el.parentElement != null && el !== document.body) {
+		el.parentElement.childNodes.forEach((siblingNode) => {
+			const siblingEl = getHTMLElementOrNull(siblingNode);
+			if (!siblingEl) return;
+			if (
+				siblingEl !== el &&
+				(
+					highestZIndexElement == null ||
+					siblingEl === highestZIndexElement.el ||
+					siblingEl.contains(highestZIndexElement.el)
+				)
+			) {
+				siblingEl.inert = false;
+			} else if (
+				highestZIndexElement != null &&
+				siblingEl !== highestZIndexElement.el &&
+				!siblingEl.contains(highestZIndexElement.el) &&
+				!ignoreElements.includes(siblingEl.tagName.toLowerCase())
+			) {
+				siblingEl.inert = true;
+			} else {
+				siblingEl.inert = false;
+			}
+		});
+		releaseFocusTrap(el.parentElement);
+	}
+}
+
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void;
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; };
+export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void {
+	const highestZIndexElement = getHighestZIndexElement();
+
+	const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex;
+	const zIndex = getZIndex(el);
+
+	// If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead
+	// Focus trapping for this element will be done in the release function
+	if (!parent && zIndex < highestZIndex) {
+		focusTrapElements.add(el);
+		if (highestZIndexElement) {
+			focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls);
+		}
+		return {
+			release: () => {
+				releaseFocusTrap(el);
+			},
+		};
+	}
+
+	if (el.inert === true) {
+		el.inert = false;
+	}
+
+	if (el.parentElement != null && el !== document.body) {
+		el.parentElement.childNodes.forEach((siblingNode) => {
+			const siblingEl = getHTMLElementOrNull(siblingNode);
+			if (!siblingEl) return;
+			if (
+				siblingEl !== el &&
+				(
+					hasInteractionWithOtherFocusTrappedEls === false ||
+					(!focusTrapElements.has(siblingEl) && !containsFocusTrappedElements(siblingEl))
+				) &&
+				!ignoreElements.includes(siblingEl.tagName.toLowerCase())
+			) {
+				siblingEl.inert = true;
+			}
+		});
+		focusTrap(el.parentElement, hasInteractionWithOtherFocusTrappedEls, true);
+	}
+
+	if (!parent) {
+		focusTrapElements.add(el);
+
+		return {
+			release: () => {
+				releaseFocusTrap(el);
+			},
+		};
+	}
+}
diff --git a/packages/frontend/src/utility/focus.ts b/packages/frontend/src/utility/focus.ts
new file mode 100644
index 0000000000..e3fd928d1d
--- /dev/null
+++ b/packages/frontend/src/utility/focus.ts
@@ -0,0 +1,80 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js';
+import { getElementOrNull, getNodeOrNull } from '@/utility/get-dom-node-or-null.js';
+
+type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;
+
+export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => {
+	if (input == null || !(input instanceof HTMLElement)) return false;
+
+	if (input.tabIndex < 0) return false;
+	if ('disabled' in input && input.disabled === true) return false;
+	if ('readonly' in input && input.readonly === true) return false;
+
+	if (!input.ownerDocument.contains(input)) return false;
+
+	const style = window.getComputedStyle(input);
+	if (style.display === 'none') return false;
+	if (style.visibility === 'hidden') return false;
+	if (style.opacity === '0') return false;
+	if (style.pointerEvents === 'none') return false;
+
+	return true;
+};
+
+export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getElementOrNull(input)?.previousElementSibling;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusPrev(element, false, scroll);
+	}
+};
+
+export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getElementOrNull(input)?.nextElementSibling;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusNext(element, false, scroll);
+	}
+};
+
+export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getNodeOrNull(input)?.parentElement;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusParent(element, false, scroll);
+	}
+};
+
+const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
+	if (scroll) {
+		const scrollContainer = getScrollContainer(element) ?? document.documentElement;
+		const scrollContainerTop = getScrollPosition(scrollContainer);
+		const stickyTop = getStickyTop(element, scrollContainer);
+		const stickyBottom = getStickyBottom(element, scrollContainer);
+		const top = element.getBoundingClientRect().top;
+		const bottom = element.getBoundingClientRect().bottom;
+
+		let scrollTo = scrollContainerTop;
+		if (top < stickyTop) {
+			scrollTo += top - stickyTop;
+		} else if (bottom > window.innerHeight - stickyBottom) {
+			scrollTo += bottom - window.innerHeight + stickyBottom;
+		}
+		scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' });
+	}
+
+	if (document.activeElement !== element) {
+		element.focus({ preventScroll: true });
+	}
+};
diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts
new file mode 100644
index 0000000000..1032e97ac9
--- /dev/null
+++ b/packages/frontend/src/utility/form.ts
@@ -0,0 +1,104 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+
+type EnumItem = string | {
+	label: string;
+	value: string;
+};
+
+type Hidden = boolean | ((v: any) => boolean);
+
+export type FormItem = {
+	label?: string;
+	type: 'string';
+	default?: string | null;
+	description?: string;
+	required?: boolean;
+	hidden?: Hidden;
+	multiline?: boolean;
+	treatAsMfm?: boolean;
+} | {
+	label?: string;
+	type: 'number';
+	default?: number | null;
+	description?: string;
+	required?: boolean;
+	hidden?: Hidden;
+	step?: number;
+} | {
+	label?: string;
+	type: 'boolean';
+	default?: boolean | null;
+	description?: string;
+	hidden?: Hidden;
+} | {
+	label?: string;
+	type: 'enum';
+	default?: string | null;
+	required?: boolean;
+	hidden?: Hidden;
+	enum: EnumItem[];
+} | {
+	label?: string;
+	type: 'radio';
+	default?: unknown | null;
+	required?: boolean;
+	hidden?: Hidden;
+	options: {
+		label: string;
+		value: unknown;
+	}[];
+} | {
+	label?: string;
+	type: 'range';
+	default?: number | null;
+	description?: string;
+	required?: boolean;
+	step?: number;
+	min: number;
+	max: number;
+	textConverter?: (value: number) => string;
+	hidden?: Hidden;
+} | {
+	label?: string;
+	type: 'object';
+	default?: Record<string, unknown> | null;
+	hidden: Hidden;
+} | {
+	label?: string;
+	type: 'array';
+	default?: unknown[] | null;
+	hidden: Hidden;
+} | {
+	type: 'button';
+	content?: string;
+	hidden?: Hidden;
+	action: (ev: MouseEvent, v: any) => void;
+} | {
+	type: 'drive-file';
+	defaultFileId?: string | null;
+	hidden?: Hidden;
+	validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>;
+};
+
+export type Form = Record<string, FormItem>;
+
+type GetItemType<Item extends FormItem> =
+	Item['type'] extends 'string' ? string :
+	Item['type'] extends 'number' ? number :
+	Item['type'] extends 'boolean' ? boolean :
+	Item['type'] extends 'radio' ? unknown :
+	Item['type'] extends 'range' ? number :
+	Item['type'] extends 'enum' ? string :
+	Item['type'] extends 'array' ? unknown[] :
+	Item['type'] extends 'object' ? Record<string, unknown> :
+	Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined :
+	never;
+
+export type GetFormResultType<F extends Form> = {
+	[P in keyof F]: GetItemType<F[P]>;
+};
diff --git a/packages/frontend/src/utility/format-time-string.ts b/packages/frontend/src/utility/format-time-string.ts
new file mode 100644
index 0000000000..d383f143e1
--- /dev/null
+++ b/packages/frontend/src/utility/format-time-string.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const defaultLocaleStringFormats: { [index: string]: string } = {
+	'weekday': 'narrow',
+	'era': 'narrow',
+	'year': 'numeric',
+	'month': 'numeric',
+	'day': 'numeric',
+	'hour': 'numeric',
+	'minute': 'numeric',
+	'second': 'numeric',
+	'timeZoneName': 'short',
+};
+
+function formatLocaleString(date: Date, format: string): string {
+	return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
+		if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
+			return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] });
+		} else {
+			return match;
+		}
+	});
+}
+
+export function formatDateTimeString(date: Date, format: string): string {
+	return format
+		.replace(/yyyy/g, date.getFullYear().toString())
+		.replace(/yy/g, date.getFullYear().toString().slice(-2))
+		.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' }))
+		.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' }))
+		.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
+		.replace(/M/g, (date.getMonth() + 1).toString())
+		.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
+		.replace(/d/g, date.getDate().toString())
+		.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
+		.replace(/H/g, date.getHours().toString())
+		.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
+		.replace(/h/g, ((date.getHours() % 12) || 12).toString())
+		.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
+		.replace(/m/g, date.getMinutes().toString())
+		.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
+		.replace(/s/g, date.getSeconds().toString())
+		.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
+}
+
+export function formatTimeString(date: Date, format: string): string {
+	return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
+		if (localeformat) return formatLocaleString(date, localeformat);
+		if (datetimeformat) return formatDateTimeString(date, datetimeformat);
+		return match;
+	});
+}
diff --git a/packages/frontend/src/utility/fullscreen.ts b/packages/frontend/src/utility/fullscreen.ts
new file mode 100644
index 0000000000..7a0a018ef3
--- /dev/null
+++ b/packages/frontend/src/utility/fullscreen.ts
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+type PartiallyPartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
+
+type VideoEl = PartiallyPartial<HTMLVideoElement, 'requestFullscreen'> & {
+	webkitEnterFullscreen?(): void;
+	webkitExitFullscreen?(): void;
+};
+
+type PlayerEl = PartiallyPartial<HTMLElement, 'requestFullscreen'>;
+
+type RequestFullscreenProps = {
+	readonly videoEl: VideoEl;
+	readonly playerEl: PlayerEl;
+	readonly options?: FullscreenOptions | null;
+};
+
+type ExitFullscreenProps = {
+	readonly videoEl: VideoEl;
+};
+
+export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscreenProps) => {
+	if (playerEl.requestFullscreen != null) {
+		playerEl.requestFullscreen(options ?? undefined);
+		return;
+	}
+	if (videoEl.webkitEnterFullscreen != null) {
+		videoEl.webkitEnterFullscreen();
+		return;
+	}
+};
+
+export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => {
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	if (document.exitFullscreen != null) {
+		document.exitFullscreen();
+		return;
+	}
+	if (videoEl.webkitExitFullscreen != null) {
+		videoEl.webkitExitFullscreen();
+		return;
+	}
+};
diff --git a/packages/frontend/src/utility/get-account-from-id.ts b/packages/frontend/src/utility/get-account-from-id.ts
new file mode 100644
index 0000000000..5d9662a747
--- /dev/null
+++ b/packages/frontend/src/utility/get-account-from-id.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { get } from '@/utility/idb-proxy.js';
+
+export async function getAccountFromId(id: string) {
+	const accounts = await get('accounts') as { token: string; id: string; }[];
+	if (!accounts) console.log('Accounts are not recorded');
+	return accounts.find(account => account.id === id);
+}
diff --git a/packages/frontend/src/utility/get-appear-note.ts b/packages/frontend/src/utility/get-appear-note.ts
new file mode 100644
index 0000000000..40ce80eac9
--- /dev/null
+++ b/packages/frontend/src/utility/get-appear-note.ts
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+
+export function getAppearNote(note: Misskey.entities.Note) {
+	return Misskey.note.isPureRenote(note) ? note.renote : note;
+}
diff --git a/packages/frontend/src/utility/get-bg-color.ts b/packages/frontend/src/utility/get-bg-color.ts
new file mode 100644
index 0000000000..ccf60b454f
--- /dev/null
+++ b/packages/frontend/src/utility/get-bg-color.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import tinycolor from 'tinycolor2';
+
+export const getBgColor = (elem?: Element | null | undefined): string | null => {
+	if (elem == null) return null;
+
+	const { backgroundColor: bg } = window.getComputedStyle(elem);
+
+	if (bg && tinycolor(bg).getAlpha() !== 0) {
+		return bg;
+	}
+
+	return getBgColor(elem.parentElement);
+};
diff --git a/packages/frontend/src/utility/get-dom-node-or-null.ts b/packages/frontend/src/utility/get-dom-node-or-null.ts
new file mode 100644
index 0000000000..fbf54675fd
--- /dev/null
+++ b/packages/frontend/src/utility/get-dom-node-or-null.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const getNodeOrNull = (input: unknown): Node | null => {
+	if (input instanceof Node) return input;
+	return null;
+};
+
+export const getElementOrNull = (input: unknown): Element | null => {
+	if (input instanceof Element) return input;
+	return null;
+};
+
+export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => {
+	if (input instanceof HTMLElement) return input;
+	return null;
+};
diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts
new file mode 100644
index 0000000000..477043a342
--- /dev/null
+++ b/packages/frontend/src/utility/get-drive-file-menu.ts
@@ -0,0 +1,162 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { defineAsyncComponent } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
+import { i18n } from '@/i18n.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+
+function rename(file: Misskey.entities.DriveFile) {
+	os.inputText({
+		title: i18n.ts.renameFile,
+		placeholder: i18n.ts.inputNewFileName,
+		default: file.name,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		misskeyApi('drive/files/update', {
+			fileId: file.id,
+			name: name,
+		});
+	});
+}
+
+function describe(file: Misskey.entities.DriveFile) {
+	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
+		default: file.comment ?? '',
+		file: file,
+	}, {
+		done: caption => {
+			misskeyApi('drive/files/update', {
+				fileId: file.id,
+				comment: caption.length === 0 ? null : caption,
+			});
+		},
+		closed: () => dispose(),
+	});
+}
+
+function move(file: Misskey.entities.DriveFile) {
+	os.selectDriveFolder(false).then(folder => {
+		misskeyApi('drive/files/update', {
+			fileId: file.id,
+			folderId: folder[0] ? folder[0].id : null,
+		});
+	});
+}
+
+function toggleSensitive(file: Misskey.entities.DriveFile) {
+	misskeyApi('drive/files/update', {
+		fileId: file.id,
+		isSensitive: !file.isSensitive,
+	}).catch(err => {
+		os.alert({
+			type: 'error',
+			title: i18n.ts.error,
+			text: err.message,
+		});
+	});
+}
+
+function copyUrl(file: Misskey.entities.DriveFile) {
+	copyToClipboard(file.url);
+	os.success();
+}
+
+/*
+function addApp() {
+	alert('not implemented yet');
+}
+*/
+async function deleteFile(file: Misskey.entities.DriveFile) {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
+	});
+
+	if (canceled) return;
+	misskeyApi('drive/files/delete', {
+		fileId: file.id,
+	});
+}
+
+export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
+	const isImage = file.type.startsWith('image/');
+
+	const menuItems: MenuItem[] = [];
+
+	menuItems.push({
+		type: 'link',
+		to: `/my/drive/file/${file.id}`,
+		text: i18n.ts._fileViewer.title,
+		icon: 'ti ti-info-circle',
+	}, { type: 'divider' }, {
+		text: i18n.ts.rename,
+		icon: 'ti ti-forms',
+		action: () => rename(file),
+	}, {
+		text: i18n.ts.move,
+		icon: 'ti ti-folder-symlink',
+		action: () => move(file),
+	}, {
+		text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
+		icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
+		action: () => toggleSensitive(file),
+	}, {
+		text: i18n.ts.describeFile,
+		icon: 'ti ti-text-caption',
+		action: () => describe(file),
+	});
+
+	if (isImage) {
+		menuItems.push({
+			text: i18n.ts.cropImage,
+			icon: 'ti ti-crop',
+			action: () => os.cropImage(file, {
+				aspectRatio: NaN,
+				uploadFolder: folder ? folder.id : folder,
+			}),
+		});
+	}
+
+	menuItems.push({ type: 'divider' }, {
+		text: i18n.ts.createNoteFromTheFile,
+		icon: 'ti ti-pencil',
+		action: () => os.post({
+			initialFiles: [file],
+		}),
+	}, {
+		text: i18n.ts.copyUrl,
+		icon: 'ti ti-link',
+		action: () => copyUrl(file),
+	}, {
+		type: 'a',
+		href: file.url,
+		target: '_blank',
+		text: i18n.ts.download,
+		icon: 'ti ti-download',
+		download: file.name,
+	}, { type: 'divider' }, {
+		text: i18n.ts.delete,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: () => deleteFile(file),
+	});
+
+	if (prefer.s.devMode) {
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-id',
+			text: i18n.ts.copyFileId,
+			action: () => {
+				copyToClipboard(file.id);
+			},
+		});
+	}
+
+	return menuItems;
+}
diff --git a/packages/frontend/src/utility/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts
new file mode 100644
index 0000000000..9021520da8
--- /dev/null
+++ b/packages/frontend/src/utility/get-embed-code.ts
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
+import { url } from '@@/js/config.js';
+import * as os from '@/os.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
+
+const MOBILE_THRESHOLD = 500;
+
+/**
+ * パラメータを正規化する（埋め込みコード作成用）
+ * @param params パラメータ
+ * @returns 正規化されたパラメータ
+ */
+export function normalizeEmbedParams(params: EmbedParams): Record<string, string> {
+	// paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す
+	const normalizedParams: Record<string, string> = {};
+	for (const key in params) {
+		// デフォルトの値と同じならparamsに含めない
+		if (params[key] == null || params[key] === defaultEmbedParams[key]) {
+			continue;
+		}
+		switch (typeof params[key]) {
+			case 'number':
+				normalizedParams[key] = params[key].toString();
+				break;
+			case 'boolean':
+				normalizedParams[key] = params[key] ? 'true' : 'false';
+				break;
+			default:
+				normalizedParams[key] = params[key];
+				break;
+		}
+	}
+	return normalizedParams;
+}
+
+/**
+ * 埋め込みコードを生成（iframe IDの発番もやる）
+ */
+export function getEmbedCode(path: string, params?: EmbedParams): string {
+	const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
+
+	let paramString = '';
+	if (params) {
+		const searchParams = new URLSearchParams(normalizeEmbedParams(params));
+		paramString = searchParams.toString() === '' ? '' : '?' + searchParams.toString();
+	}
+
+	const iframeCode = [
+		`<iframe src="${url + path + paramString}" data-misskey-embed-id="${iframeId}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="border: none; width: 100%; max-width: 500px; height: 300px; color-scheme: light dark;"></iframe>`,
+		`<script defer src="${url}/embed.js"></script>`,
+	];
+	return iframeCode.join('\n');
+}
+
+/**
+ * 埋め込みコードを生成してコピーする（カスタマイズ機能つき）
+ *
+ * カスタマイズ機能がいらない場合（事前にパラメータを指定する場合）は getEmbedCode を直接使ってください
+ */
+export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
+	const _params = { ...params };
+
+	if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
+		_params.maxHeight = 700;
+	}
+
+	// PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
+	if (window.innerWidth < MOBILE_THRESHOLD) {
+		copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
+		os.success();
+	} else {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
+			entity,
+			id,
+			params: _params,
+		}, {
+			closed: () => dispose(),
+		});
+	}
+}
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
new file mode 100644
index 0000000000..c95eaa20dd
--- /dev/null
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -0,0 +1,685 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent } from 'vue';
+import type { Ref, ShallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import { claimAchievement } from './achievements.js';
+import type { MenuItem } from '@/types/menu.js';
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { store, noteActions } from '@/store.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { getUserMenu } from '@/utility/get-user-menu.js';
+import { clipsCache, favoritedChannelsCache } from '@/cache.js';
+import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { isSupportShare } from '@/utility/navigator.js';
+import { getAppearNote } from '@/utility/get-appear-note.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
+import { prefer } from '@/preferences.js';
+
+export async function getNoteClipMenu(props: {
+	note: Misskey.entities.Note;
+	isDeleted: Ref<boolean>;
+	currentClip?: Misskey.entities.Clip;
+}) {
+	function getClipName(clip: Misskey.entities.Clip) {
+		if ($i && clip.userId === $i.id && clip.notesCount != null) {
+			return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
+		} else {
+			return clip.name;
+		}
+	}
+
+	const appearNote = getAppearNote(props.note);
+
+	const clips = await clipsCache.fetch();
+	const menu: MenuItem[] = [...clips.map(clip => ({
+		text: getClipName(clip),
+		action: () => {
+			claimAchievement('noteClipped1');
+			os.promiseDialog(
+				misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
+				null,
+				async (err) => {
+					if (err.id === '734806c4-542c-463a-9311-15c512803965') {
+						const confirm = await os.confirm({
+							type: 'warning',
+							text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
+						});
+						if (!confirm.canceled) {
+							os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
+								clipsCache.set(clips.map(c => {
+									if (c.id === clip.id) {
+										return {
+											...c,
+											notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
+										};
+									} else {
+										return c;
+									}
+								}));
+							});
+							if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
+						}
+					} else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
+						os.alert({
+							type: 'error',
+							text: i18n.ts.clipNoteLimitExceeded,
+						});
+					} else {
+						os.alert({
+							type: 'error',
+							text: err.message + '\n' + err.id,
+						});
+					}
+				},
+			).then(() => {
+				clipsCache.set(clips.map(c => {
+					if (c.id === clip.id) {
+						return {
+							...c,
+							notesCount: (c.notesCount ?? 0) + 1,
+						};
+					} else {
+						return c;
+					}
+				}));
+			});
+		},
+	})), { type: 'divider' }, {
+		icon: 'ti ti-plus',
+		text: i18n.ts.createNew,
+		action: async () => {
+			const { canceled, result } = await os.form(i18n.ts.createNewClip, {
+				name: {
+					type: 'string',
+					default: null,
+					label: i18n.ts.name,
+				},
+				description: {
+					type: 'string',
+					required: false,
+					default: null,
+					multiline: true,
+					label: i18n.ts.description,
+				},
+				isPublic: {
+					type: 'boolean',
+					label: i18n.ts.public,
+					default: false,
+				},
+			});
+			if (canceled) return;
+
+			const clip = await os.apiWithDialog('clips/create', result);
+
+			clipsCache.delete();
+
+			claimAchievement('noteClipped1');
+			os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+		},
+	}];
+
+	return menu;
+}
+
+export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
+	return {
+		icon: 'ti ti-exclamation-circle',
+		text,
+		action: (): void => {
+			const localUrl = `${url}/notes/${note.id}`;
+			let noteInfo = '';
+			if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
+			noteInfo += `Local Note: ${localUrl}\n`;
+			const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+				user: note.user,
+				initialComment: `${noteInfo}-----\n`,
+			}, {
+				closed: () => dispose(),
+			});
+		},
+	};
+}
+
+export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
+	return {
+		icon: 'ti ti-link',
+		text,
+		action: (): void => {
+			copyToClipboard(`${url}/notes/${note.id}`);
+			os.success();
+		},
+	};
+}
+
+function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
+	if (note.url != null || note.uri != null) return undefined;
+	if (['specified', 'followers'].includes(note.visibility)) return undefined;
+
+	return {
+		icon: 'ti ti-code',
+		text,
+		action: (): void => {
+			genEmbedCode('notes', note.id);
+		},
+	};
+}
+
+export function getNoteMenu(props: {
+	note: Misskey.entities.Note;
+	translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
+	translating: Ref<boolean>;
+	isDeleted: Ref<boolean>;
+	currentClip?: Misskey.entities.Clip;
+}) {
+	const appearNote = getAppearNote(props.note);
+
+	const cleanups = [] as (() => void)[];
+
+	function del(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.ts.noteDeleteConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			misskeyApi('notes/delete', {
+				noteId: appearNote.id,
+			});
+
+			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
+				claimAchievement('noteDeletedWithin1min');
+			}
+		});
+	}
+
+	function delEdit(): void {
+		os.confirm({
+			type: 'warning',
+			text: i18n.ts.deleteAndEditConfirm,
+		}).then(({ canceled }) => {
+			if (canceled) return;
+
+			misskeyApi('notes/delete', {
+				noteId: appearNote.id,
+			});
+
+			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+
+			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
+				claimAchievement('noteDeletedWithin1min');
+			}
+		});
+	}
+
+	function toggleFavorite(favorite: boolean): void {
+		claimAchievement('noteFavorited1');
+		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+			noteId: appearNote.id,
+		});
+	}
+
+	function toggleThreadMute(mute: boolean): void {
+		os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+			noteId: appearNote.id,
+		});
+	}
+
+	function copyContent(): void {
+		copyToClipboard(appearNote.text);
+		os.success();
+	}
+
+	function togglePin(pin: boolean): void {
+		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+			noteId: appearNote.id,
+		}, undefined, {
+			'72dab508-c64d-498f-8740-a8eec1ba385a': {
+				text: i18n.ts.pinLimitExceeded,
+			},
+		});
+	}
+
+	async function unclip(): Promise<void> {
+		if (!props.currentClip) return;
+		os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
+		props.isDeleted.value = true;
+	}
+
+	async function promote(): Promise<void> {
+		const { canceled, result: days } = await os.inputNumber({
+			title: i18n.ts.numberOfDays,
+		});
+
+		if (canceled || days == null) return;
+
+		os.apiWithDialog('admin/promo/create', {
+			noteId: appearNote.id,
+			expiresAt: Date.now() + (86400000 * days),
+		});
+	}
+
+	function share(): void {
+		navigator.share({
+			title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }),
+			text: appearNote.text ?? '',
+			url: `${url}/notes/${appearNote.id}`,
+		});
+	}
+
+	function openDetail(): void {
+		os.pageWindow(`/notes/${appearNote.id}`);
+	}
+
+	async function translate(): Promise<void> {
+		if (props.translation.value != null) return;
+		props.translating.value = true;
+		const res = await misskeyApi('notes/translate', {
+			noteId: appearNote.id,
+			targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
+		});
+		props.translating.value = false;
+		props.translation.value = res;
+	}
+
+	const menuItems: MenuItem[] = [];
+
+	if ($i) {
+		const statePromise = misskeyApi('notes/state', {
+			noteId: appearNote.id,
+		});
+
+		if (props.currentClip?.userId === $i.id) {
+			menuItems.push({
+				icon: 'ti ti-backspace',
+				text: i18n.ts.unclip,
+				danger: true,
+				action: unclip,
+			}, { type: 'divider' });
+		}
+
+		menuItems.push({
+			icon: 'ti ti-info-circle',
+			text: i18n.ts.details,
+			action: openDetail,
+		}, {
+			icon: 'ti ti-copy',
+			text: i18n.ts.copyContent,
+			action: copyContent,
+		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
+
+		if (appearNote.url || appearNote.uri) {
+			menuItems.push({
+				icon: 'ti ti-link',
+				text: i18n.ts.copyRemoteLink,
+				action: () => {
+					copyToClipboard(appearNote.url ?? appearNote.uri);
+					os.success();
+				},
+			}, {
+				icon: 'ti ti-external-link',
+				text: i18n.ts.showOnRemote,
+				action: () => {
+					window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
+				},
+			});
+		} else {
+			menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
+		}
+
+		if (isSupportShare()) {
+			menuItems.push({
+				icon: 'ti ti-share',
+				text: i18n.ts.share,
+				action: share,
+			});
+		}
+
+		if ($i.policies.canUseTranslator && instance.translatorAvailable) {
+			menuItems.push({
+				icon: 'ti ti-language-hiragana',
+				text: i18n.ts.translate,
+				action: translate,
+			});
+		}
+
+		menuItems.push({ type: 'divider' });
+
+		menuItems.push(statePromise.then(state => state.isFavorited ? {
+			icon: 'ti ti-star-off',
+			text: i18n.ts.unfavorite,
+			action: () => toggleFavorite(false),
+		} : {
+			icon: 'ti ti-star',
+			text: i18n.ts.favorite,
+			action: () => toggleFavorite(true),
+		}));
+
+		menuItems.push({
+			type: 'parent',
+			icon: 'ti ti-paperclip',
+			text: i18n.ts.clip,
+			children: () => getNoteClipMenu(props),
+		});
+
+		menuItems.push(statePromise.then(state => state.isMutedThread ? {
+			icon: 'ti ti-message-off',
+			text: i18n.ts.unmuteThread,
+			action: () => toggleThreadMute(false),
+		} : {
+			icon: 'ti ti-message-off',
+			text: i18n.ts.muteThread,
+			action: () => toggleThreadMute(true),
+		}));
+
+		if (appearNote.userId === $i.id) {
+			if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
+				menuItems.push({
+					icon: 'ti ti-pinned-off',
+					text: i18n.ts.unpin,
+					action: () => togglePin(false),
+				});
+			} else {
+				menuItems.push({
+					icon: 'ti ti-pin',
+					text: i18n.ts.pin,
+					action: () => togglePin(true),
+				});
+			}
+		}
+
+		menuItems.push({
+			type: 'parent',
+			icon: 'ti ti-user',
+			text: i18n.ts.user,
+			children: async () => {
+				const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
+				const { menu, cleanup } = getUserMenu(user);
+				cleanups.push(cleanup);
+				return menu;
+			},
+		});
+
+		if (appearNote.userId !== $i.id) {
+			menuItems.push({ type: 'divider' });
+			menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
+		}
+
+		if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {
+			menuItems.push({ type: 'divider' });
+			menuItems.push({
+				type: 'parent',
+				icon: 'ti ti-device-tv',
+				text: i18n.ts.channel,
+				children: async () => {
+					const channelChildMenu = [] as MenuItem[];
+
+					const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
+
+					if (channel.pinnedNoteIds.includes(appearNote.id)) {
+						channelChildMenu.push({
+							icon: 'ti ti-pinned-off',
+							text: i18n.ts.unpin,
+							action: () => os.apiWithDialog('channels/update', {
+								channelId: appearNote.channel!.id,
+								pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
+							}),
+						});
+					} else {
+						channelChildMenu.push({
+							icon: 'ti ti-pin',
+							text: i18n.ts.pin,
+							action: () => os.apiWithDialog('channels/update', {
+								channelId: appearNote.channel!.id,
+								pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
+							}),
+						});
+					}
+					return channelChildMenu;
+				},
+			});
+		}
+
+		if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) {
+			menuItems.push({ type: 'divider' });
+			if (appearNote.userId === $i.id) {
+				menuItems.push({
+					icon: 'ti ti-edit',
+					text: i18n.ts.deleteAndEdit,
+					action: delEdit,
+				});
+			}
+			menuItems.push({
+				icon: 'ti ti-trash',
+				text: i18n.ts.delete,
+				danger: true,
+				action: del,
+			});
+		}
+	} else {
+		menuItems.push({
+			icon: 'ti ti-info-circle',
+			text: i18n.ts.details,
+			action: openDetail,
+		}, {
+			icon: 'ti ti-copy',
+			text: i18n.ts.copyContent,
+			action: copyContent,
+		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
+
+		if (appearNote.url || appearNote.uri) {
+			menuItems.push({
+				icon: 'ti ti-link',
+				text: i18n.ts.copyRemoteLink,
+				action: () => {
+					copyToClipboard(appearNote.url ?? appearNote.uri);
+					os.success();
+				},
+			}, {
+				icon: 'ti ti-external-link',
+				text: i18n.ts.showOnRemote,
+				action: () => {
+					window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
+				},
+			});
+		} else {
+			menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
+		}
+	}
+
+	if (noteActions.length > 0) {
+		menuItems.push({ type: 'divider' });
+
+		menuItems.push(...noteActions.map(action => ({
+			icon: 'ti ti-plug',
+			text: action.title,
+			action: () => {
+				action.handler(appearNote);
+			},
+		})));
+	}
+
+	if (prefer.s.devMode) {
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-id',
+			text: i18n.ts.copyNoteId,
+			action: () => {
+				copyToClipboard(appearNote.id);
+				os.success();
+			},
+		});
+	}
+
+	const cleanup = () => {
+		if (_DEV_) console.log('note menu cleanup', cleanups);
+		for (const cl of cleanups) {
+			cl();
+		}
+	};
+
+	return {
+		menu: menuItems,
+		cleanup,
+	};
+}
+
+type Visibility = (typeof Misskey.noteVisibilities)[number];
+
+function smallerVisibility(a: Visibility, b: Visibility): Visibility {
+	if (a === 'specified' || b === 'specified') return 'specified';
+	if (a === 'followers' || b === 'followers') return 'followers';
+	if (a === 'home' || b === 'home') return 'home';
+	// if (a === 'public' || b === 'public')
+	return 'public';
+}
+
+export function getRenoteMenu(props: {
+	note: Misskey.entities.Note;
+	renoteButton: ShallowRef<HTMLElement | undefined>;
+	mock?: boolean;
+}) {
+	const appearNote = getAppearNote(props.note);
+
+	const channelRenoteItems: MenuItem[] = [];
+	const normalRenoteItems: MenuItem[] = [];
+	const normalExternalChannelRenoteItems: MenuItem[] = [];
+
+	if (appearNote.channel) {
+		channelRenoteItems.push(...[{
+			text: i18n.ts.inChannelRenote,
+			icon: 'ti ti-repeat',
+			action: () => {
+				const el = props.renoteButton.value;
+				if (el && prefer.s.animation) {
+					const rect = el.getBoundingClientRect();
+					const x = rect.left + (el.offsetWidth / 2);
+					const y = rect.top + (el.offsetHeight / 2);
+					const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+						end: () => dispose(),
+					});
+				}
+
+				if (!props.mock) {
+					misskeyApi('notes/create', {
+						renoteId: appearNote.id,
+						channelId: appearNote.channelId,
+					}).then(() => {
+						os.toast(i18n.ts.renoted);
+					});
+				}
+			},
+		}, {
+			text: i18n.ts.inChannelQuote,
+			icon: 'ti ti-quote',
+			action: () => {
+				if (!props.mock) {
+					os.post({
+						renote: appearNote,
+						channel: appearNote.channel,
+					});
+				}
+			},
+		}]);
+	}
+
+	if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
+		normalRenoteItems.push(...[{
+			text: i18n.ts.renote,
+			icon: 'ti ti-repeat',
+			action: () => {
+				const el = props.renoteButton.value;
+				if (el && prefer.s.animation) {
+					const rect = el.getBoundingClientRect();
+					const x = rect.left + (el.offsetWidth / 2);
+					const y = rect.top + (el.offsetHeight / 2);
+					const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+						end: () => dispose(),
+					});
+				}
+
+				const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility;
+				const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly;
+
+				let visibility = appearNote.visibility;
+				visibility = smallerVisibility(visibility, configuredVisibility);
+				if (appearNote.channel?.isSensitive) {
+					visibility = smallerVisibility(visibility, 'home');
+				}
+
+				if (!props.mock) {
+					misskeyApi('notes/create', {
+						localOnly,
+						visibility,
+						renoteId: appearNote.id,
+					}).then(() => {
+						os.toast(i18n.ts.renoted);
+					});
+				}
+			},
+		}, (props.mock) ? undefined : {
+			text: i18n.ts.quote,
+			icon: 'ti ti-quote',
+			action: () => {
+				os.post({
+					renote: appearNote,
+				});
+			},
+		}]);
+
+		normalExternalChannelRenoteItems.push({
+			type: 'parent',
+			icon: 'ti ti-repeat',
+			text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
+			children: async () => {
+				const channels = await favoritedChannelsCache.fetch();
+				return channels.filter((channel) => {
+					if (!appearNote.channelId) return true;
+					return channel.id !== appearNote.channelId;
+				}).map((channel) => ({
+					text: channel.name,
+					action: () => {
+						const el = props.renoteButton.value;
+						if (el && prefer.s.animation) {
+							const rect = el.getBoundingClientRect();
+							const x = rect.left + (el.offsetWidth / 2);
+							const y = rect.top + (el.offsetHeight / 2);
+							const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+								end: () => dispose(),
+							});
+						}
+
+						if (!props.mock) {
+							misskeyApi('notes/create', {
+								renoteId: appearNote.id,
+								channelId: channel.id,
+							}).then(() => {
+								os.toast(i18n.tsx.renotedToX({ name: channel.name }));
+							});
+						}
+					},
+				}));
+			},
+		});
+	}
+
+	const renoteItems = [
+		...normalRenoteItems,
+		...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
+		...channelRenoteItems,
+		...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
+		...normalExternalChannelRenoteItems,
+	];
+
+	return {
+		menu: renoteItems,
+	};
+}
diff --git a/packages/frontend/src/utility/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts
new file mode 100644
index 0000000000..6fd9947ac1
--- /dev/null
+++ b/packages/frontend/src/utility/get-note-summary.ts
@@ -0,0 +1,64 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} note (packされた)投稿
+ */
+export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
+	if (note == null) {
+		return '';
+	}
+
+	if (note.deletedAt) {
+		return `(${i18n.ts.deletedNote})`;
+	}
+
+	if (note.isHidden) {
+		return `(${i18n.ts.invisibleNote})`;
+	}
+
+	let summary = '';
+
+	// 本文
+	if (note.cw != null) {
+		summary += note.cw;
+	} else {
+		summary += note.text ? note.text : '';
+	}
+
+	// ファイルが添付されているとき
+	if ((note.files || []).length !== 0) {
+		summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
+	}
+
+	// 投票が添付されているとき
+	if (note.poll) {
+		summary += ` (${i18n.ts.poll})`;
+	}
+
+	// 返信のとき
+	if (note.replyId) {
+		if (note.reply) {
+			summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
+		} else {
+			summary += '\n\nRE: ...';
+		}
+	}
+
+	// Renoteのとき
+	if (note.renoteId) {
+		if (note.renote) {
+			summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
+		} else {
+			summary += '\n\nRN: ...';
+		}
+	}
+
+	return summary.trim();
+};
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
new file mode 100644
index 0000000000..d739976cb1
--- /dev/null
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -0,0 +1,441 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { toUnicode } from 'punycode.js';
+import { defineAsyncComponent, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import { host, url } from '@@/js/config.js';
+import type { IRouter } from '@/nirax.js';
+import type { MenuItem } from '@/types/menu.js';
+import { i18n } from '@/i18n.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { userActions } from '@/store.js';
+import { $i, iAmModerator } from '@/account.js';
+import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
+import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
+import { mainRouter } from '@/router/main.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
+import { prefer } from '@/preferences.js';
+
+export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
+	const meId = $i ? $i.id : null;
+
+	const cleanups = [] as (() => void)[];
+
+	async function toggleMute() {
+		if (user.isMuted) {
+			os.apiWithDialog('mute/delete', {
+				userId: user.id,
+			}).then(() => {
+				user.isMuted = false;
+			});
+		} else {
+			const { canceled, result: period } = await os.select({
+				title: i18n.ts.mutePeriod,
+				items: [{
+					value: 'indefinitely', text: i18n.ts.indefinitely,
+				}, {
+					value: 'tenMinutes', text: i18n.ts.tenMinutes,
+				}, {
+					value: 'oneHour', text: i18n.ts.oneHour,
+				}, {
+					value: 'oneDay', text: i18n.ts.oneDay,
+				}, {
+					value: 'oneWeek', text: i18n.ts.oneWeek,
+				}],
+				default: 'indefinitely',
+			});
+			if (canceled) return;
+
+			const expiresAt = period === 'indefinitely' ? null
+				: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
+				: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+				: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+				: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+				: null;
+
+			os.apiWithDialog('mute/create', {
+				userId: user.id,
+				expiresAt,
+			}).then(() => {
+				user.isMuted = true;
+			});
+		}
+	}
+
+	async function toggleRenoteMute() {
+		os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
+			userId: user.id,
+		}).then(() => {
+			user.isRenoteMuted = !user.isRenoteMuted;
+		});
+	}
+
+	async function toggleBlock() {
+		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
+
+		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
+			userId: user.id,
+		}).then(() => {
+			user.isBlocking = !user.isBlocking;
+		});
+	}
+
+	async function toggleNotify() {
+		os.apiWithDialog('following/update', {
+			userId: user.id,
+			notify: user.notify === 'normal' ? 'none' : 'normal',
+		}).then(() => {
+			user.notify = user.notify === 'normal' ? 'none' : 'normal';
+		});
+	}
+
+	function reportAbuse() {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+			user: user,
+		}, {
+			closed: () => dispose(),
+		});
+	}
+
+	async function getConfirmed(text: string): Promise<boolean> {
+		const confirm = await os.confirm({
+			type: 'warning',
+			title: 'confirm',
+			text,
+		});
+
+		return !confirm.canceled;
+	}
+
+	async function userInfoUpdate() {
+		os.apiWithDialog('federation/update-remote-user', {
+			userId: user.id,
+		});
+	}
+
+	async function invalidateFollow() {
+		if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
+
+		os.apiWithDialog('following/invalidate', {
+			userId: user.id,
+		}).then(() => {
+			user.isFollowed = !user.isFollowed;
+		});
+	}
+
+	async function editMemo(): Promise<void> {
+		const userDetailed = await misskeyApi('users/show', {
+			userId: user.id,
+		});
+		const { canceled, result } = await os.form(i18n.ts.editMemo, {
+			memo: {
+				type: 'string',
+				required: true,
+				multiline: true,
+				label: i18n.ts.memo,
+				default: userDetailed.memo,
+			},
+		});
+		if (canceled) return;
+
+		os.apiWithDialog('users/update-memo', {
+			memo: result.memo,
+			userId: user.id,
+		});
+	}
+
+	const menuItems: MenuItem[] = [];
+
+	menuItems.push({
+		icon: 'ti ti-at',
+		text: i18n.ts.copyUsername,
+		action: () => {
+			copyToClipboard(`@${user.username}@${user.host ?? host}`);
+		},
+	});
+
+	if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
+		menuItems.push({
+			icon: 'ti ti-search',
+			text: i18n.ts.searchThisUsersNotes,
+			action: () => {
+				router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
+			},
+		});
+	}
+
+	if (iAmModerator) {
+		menuItems.push({
+			icon: 'ti ti-user-exclamation',
+			text: i18n.ts.moderation,
+			action: () => {
+				router.push(`/admin/user/${user.id}`);
+			},
+		});
+	}
+
+	menuItems.push({
+		icon: 'ti ti-rss',
+		text: i18n.ts.copyRSS,
+		action: () => {
+			copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
+		},
+	});
+
+	if (user.host != null && user.url != null) {
+		menuItems.push({
+			icon: 'ti ti-external-link',
+			text: i18n.ts.showOnRemote,
+			action: () => {
+				if (user.url == null) return;
+				window.open(user.url, '_blank', 'noopener');
+			},
+		});
+	} else {
+		menuItems.push({
+			icon: 'ti ti-code',
+			text: i18n.ts.genEmbedCode,
+			type: 'parent',
+			children: [{
+				text: i18n.ts.noteOfThisUser,
+				action: () => {
+					genEmbedCode('user-timeline', user.id);
+				},
+			}], // TODO: ユーザーカードの埋め込みなど
+		});
+	}
+
+	menuItems.push({
+		icon: 'ti ti-share',
+		text: i18n.ts.copyProfileUrl,
+		action: () => {
+			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+			copyToClipboard(`${url}/${canonical}`);
+		},
+	});
+
+	if ($i) {
+		menuItems.push({
+			icon: 'ti ti-mail',
+			text: i18n.ts.sendMessage,
+			action: () => {
+				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
+				os.post({ specified: user, initialText: `${canonical} ` });
+			},
+		}, { type: 'divider' }, {
+			icon: 'ti ti-pencil',
+			text: i18n.ts.editMemo,
+			action: editMemo,
+		}, {
+			type: 'parent',
+			icon: 'ti ti-list',
+			text: i18n.ts.addToList,
+			children: async () => {
+				const lists = await userListsCache.fetch();
+				return lists.map(list => {
+					const isListed = ref(list.userIds?.includes(user.id) ?? false);
+					cleanups.push(watch(isListed, () => {
+						if (isListed.value) {
+							os.apiWithDialog('users/lists/push', {
+								listId: list.id,
+								userId: user.id,
+							}).then(() => {
+								list.userIds?.push(user.id);
+							});
+						} else {
+							os.apiWithDialog('users/lists/pull', {
+								listId: list.id,
+								userId: user.id,
+							}).then(() => {
+								list.userIds?.splice(list.userIds.indexOf(user.id), 1);
+							});
+						}
+					}));
+
+					return {
+						type: 'switch',
+						text: list.name,
+						ref: isListed,
+					};
+				});
+			},
+		}, {
+			type: 'parent',
+			icon: 'ti ti-antenna',
+			text: i18n.ts.addToAntenna,
+			children: async () => {
+				const antennas = await antennasCache.fetch();
+				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+				return antennas.filter((a) => a.src === 'users').map(antenna => ({
+					text: antenna.name,
+					action: async () => {
+						await os.apiWithDialog('antennas/update', {
+							antennaId: antenna.id,
+							name: antenna.name,
+							keywords: antenna.keywords,
+							excludeKeywords: antenna.excludeKeywords,
+							src: antenna.src,
+							userListId: antenna.userListId,
+							users: [...antenna.users, canonical],
+							caseSensitive: antenna.caseSensitive,
+							withReplies: antenna.withReplies,
+							withFile: antenna.withFile,
+							notify: antenna.notify,
+						});
+						antennasCache.delete();
+					},
+				}));
+			},
+		});
+	}
+
+	if ($i && meId !== user.id) {
+		if (iAmModerator) {
+			menuItems.push({
+				type: 'parent',
+				icon: 'ti ti-badges',
+				text: i18n.ts.roles,
+				children: async () => {
+					const roles = await rolesCache.fetch();
+
+					return roles.filter(r => r.target === 'manual').map(r => ({
+						text: r.name,
+						action: async () => {
+							const { canceled, result: period } = await os.select({
+								title: i18n.ts.period + ': ' + r.name,
+								items: [{
+									value: 'indefinitely', text: i18n.ts.indefinitely,
+								}, {
+									value: 'oneHour', text: i18n.ts.oneHour,
+								}, {
+									value: 'oneDay', text: i18n.ts.oneDay,
+								}, {
+									value: 'oneWeek', text: i18n.ts.oneWeek,
+								}, {
+									value: 'oneMonth', text: i18n.ts.oneMonth,
+								}],
+								default: 'indefinitely',
+							});
+							if (canceled) return;
+
+							const expiresAt = period === 'indefinitely' ? null
+								: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+								: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+								: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+								: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+								: null;
+
+							os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
+						},
+					}));
+				},
+			});
+		}
+
+		// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
+		//if (user.isFollowing) {
+		const withRepliesRef = ref(user.withReplies ?? false);
+
+		menuItems.push({
+			type: 'switch',
+			icon: 'ti ti-messages',
+			text: i18n.ts.showRepliesToOthersInTimeline,
+			ref: withRepliesRef,
+		}, {
+			icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
+			text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
+			action: toggleNotify,
+		});
+
+		watch(withRepliesRef, (withReplies) => {
+			misskeyApi('following/update', {
+				userId: user.id,
+				withReplies,
+			}).then(() => {
+				user.withReplies = withReplies;
+			});
+		});
+		//}
+
+		menuItems.push({ type: 'divider' }, {
+			icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
+			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
+			action: toggleMute,
+		}, {
+			icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off',
+			text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
+			action: toggleRenoteMute,
+		}, {
+			icon: 'ti ti-ban',
+			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
+			action: toggleBlock,
+		});
+
+		if (user.isFollowed) {
+			menuItems.push({
+				icon: 'ti ti-link-off',
+				text: i18n.ts.breakFollow,
+				action: invalidateFollow,
+			});
+		}
+
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-exclamation-circle',
+			text: i18n.ts.reportAbuse,
+			action: reportAbuse,
+		});
+	}
+
+	if (user.host !== null) {
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-refresh',
+			text: i18n.ts.updateRemoteUser,
+			action: userInfoUpdate,
+		});
+	}
+
+	if (prefer.s.devMode) {
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-id',
+			text: i18n.ts.copyUserId,
+			action: () => {
+				copyToClipboard(user.id);
+			},
+		});
+	}
+
+	if ($i && meId === user.id) {
+		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-pencil',
+			text: i18n.ts.editProfile,
+			action: () => {
+				router.push('/settings/profile');
+			},
+		});
+	}
+
+	if (userActions.length > 0) {
+		menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
+			icon: 'ti ti-plug',
+			text: action.title,
+			action: () => {
+				action.handler(user);
+			},
+		})));
+	}
+
+	return {
+		menu: menuItems,
+		cleanup: () => {
+			if (_DEV_) console.log('user menu cleanup', cleanups);
+			for (const cl of cleanups) {
+				cl();
+			}
+		},
+	};
+}
diff --git a/packages/frontend/src/utility/get-user-name.ts b/packages/frontend/src/utility/get-user-name.ts
new file mode 100644
index 0000000000..56e91abba0
--- /dev/null
+++ b/packages/frontend/src/utility/get-user-name.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export default function(user: { name?: string | null, username: string }): string {
+	return user.name === '' ? user.username : user.name ?? user.username;
+}
diff --git a/packages/frontend/src/utility/hotkey.ts b/packages/frontend/src/utility/hotkey.ts
new file mode 100644
index 0000000000..fe62139a74
--- /dev/null
+++ b/packages/frontend/src/utility/hotkey.ts
@@ -0,0 +1,172 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { getHTMLElementOrNull } from "@/utility/get-dom-node-or-null.js";
+
+//#region types
+export type Keymap = Record<string, CallbackFunction | CallbackObject>;
+
+type CallbackFunction = (ev: KeyboardEvent) => unknown;
+
+type CallbackObject = {
+	callback: CallbackFunction;
+	allowRepeat?: boolean;
+};
+
+type Pattern = {
+	which: string[];
+	ctrl: boolean;
+	alt: boolean;
+	shift: boolean;
+};
+
+type Action = {
+	patterns: Pattern[];
+	callback: CallbackFunction;
+	options: Required<Omit<CallbackObject, 'callback'>>;
+};
+//#endregion
+
+//#region consts
+const KEY_ALIASES = {
+	'esc': 'Escape',
+	'enter': 'Enter',
+	'space': ' ',
+	'up': 'ArrowUp',
+	'down': 'ArrowDown',
+	'left': 'ArrowLeft',
+	'right': 'ArrowRight',
+	'plus': ['+', ';'],
+};
+
+const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
+
+const IGNORE_ELEMENTS = ['input', 'textarea'];
+//#endregion
+
+//#region store
+let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
+//#endregion
+
+//#region impl
+export const makeHotkey = (keymap: Keymap) => {
+	const actions = parseKeymap(keymap);
+	return (ev: KeyboardEvent) => {
+		if ('pswp' in window && window.pswp != null) return;
+		if (document.activeElement != null) {
+			if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
+			if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
+		}
+		for (const action of actions) {
+			if (matchPatterns(ev, action)) {
+				ev.preventDefault();
+				ev.stopPropagation();
+				action.callback(ev);
+				storePattern(ev, action.callback);
+			}
+		}
+	};
+};
+
+const parseKeymap = (keymap: Keymap) => {
+	return Object.entries(keymap).map(([rawPatterns, rawCallback]) => {
+		const patterns = parsePatterns(rawPatterns);
+		const callback = parseCallback(rawCallback);
+		const options = parseOptions(rawCallback);
+		return { patterns, callback, options } as const satisfies Action;
+	});
+};
+
+const parsePatterns = (rawPatterns: keyof Keymap) => {
+	return rawPatterns.split('|').map(part => {
+		const keys = part.split('+').map(trimLower);
+		const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x)));
+		const ctrl = keys.includes('ctrl');
+		const alt = keys.includes('alt');
+		const shift = keys.includes('shift');
+		return { which, ctrl, alt, shift } as const satisfies Pattern;
+	});
+};
+
+const parseCallback = (rawCallback: Keymap[keyof Keymap]) => {
+	if (typeof rawCallback === 'object') {
+		return rawCallback.callback;
+	}
+	return rawCallback;
+};
+
+const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
+	const defaultOptions = {
+		allowRepeat: false,
+	} as const satisfies Action['options'];
+	if (typeof rawCallback === 'object') {
+		const { callback, ...rawOptions } = rawCallback;
+		const options = { ...defaultOptions, ...rawOptions };
+		return { ...options } as const satisfies Action['options'];
+	}
+	return { ...defaultOptions } as const satisfies Action['options'];
+};
+
+const matchPatterns = (ev: KeyboardEvent, action: Action) => {
+	const { patterns, options, callback } = action;
+	if (ev.repeat && !options.allowRepeat) return false;
+	const key = ev.key.toLowerCase();
+	return patterns.some(({ which, ctrl, shift, alt }) => {
+		if (
+			options.allowRepeat === false &&
+			latestHotkey != null &&
+			latestHotkey.which.includes(key) &&
+			latestHotkey.ctrl === ctrl &&
+			latestHotkey.alt === alt &&
+			latestHotkey.shift === shift &&
+			latestHotkey.callback === callback
+		) {
+			return false;
+		}
+		if (!which.includes(key)) return false;
+		if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
+		if (alt !== ev.altKey) return false;
+		if (shift !== ev.shiftKey) return false;
+		return true;
+	});
+};
+
+let lastHotKeyStoreTimer: number | null = null;
+
+const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
+	if (lastHotKeyStoreTimer != null) {
+		clearTimeout(lastHotKeyStoreTimer);
+	}
+
+	latestHotkey = {
+		which: [ev.key.toLowerCase()],
+		ctrl: ev.ctrlKey || ev.metaKey,
+		alt: ev.altKey,
+		shift: ev.shiftKey,
+		callback,
+	};
+
+	lastHotKeyStoreTimer = window.setTimeout(() => {
+		latestHotkey = null;
+	}, 500);
+};
+
+const parseKeyCode = (input?: string | null) => {
+	if (input == null) return [];
+	const raw = getValueByKey(KEY_ALIASES, input);
+	if (raw == null) return [input];
+	if (typeof raw === 'string') return [trimLower(raw)];
+	return raw.map(trimLower);
+};
+
+const getValueByKey = <
+	T extends Record<keyof any, unknown>,
+	K extends keyof T | keyof any,
+	R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
+>(obj: T, key: K) => {
+	return obj[key] as R;
+};
+
+const trimLower = (str: string) => str.trim().toLowerCase();
+//#endregion
diff --git a/packages/frontend/src/utility/idb-proxy.ts b/packages/frontend/src/utility/idb-proxy.ts
new file mode 100644
index 0000000000..20f51660c7
--- /dev/null
+++ b/packages/frontend/src/utility/idb-proxy.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
+// indexedDBが使えない環境ではlocalStorageを使う
+import {
+	get as iget,
+	set as iset,
+	del as idel,
+} from 'idb-keyval';
+import { miLocalStorage } from '@/local-storage.js';
+
+const PREFIX = 'idbfallback::';
+
+let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
+
+// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
+// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
+// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+if (window.Cypress) {
+	idbAvailable = false;
+	console.log('Cypress detected. It will use localStorage.');
+}
+
+if (idbAvailable) {
+	await iset('idb-test', 'test')
+		.catch(err => {
+			console.error('idb error', err);
+			console.error('indexedDB is unavailable. It will use localStorage.');
+			idbAvailable = false;
+		});
+} else {
+	console.error('indexedDB is unavailable. It will use localStorage.');
+}
+
+export async function get(key: string) {
+	if (idbAvailable) return iget(key);
+	return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
+}
+
+export async function set(key: string, val: any) {
+	if (idbAvailable) return iset(key, val);
+	return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
+}
+
+export async function del(key: string) {
+	if (idbAvailable) return idel(key);
+	return miLocalStorage.removeItem(`${PREFIX}${key}`);
+}
diff --git a/packages/frontend/src/utility/idle-render.ts b/packages/frontend/src/utility/idle-render.ts
new file mode 100644
index 0000000000..6adfedcb9f
--- /dev/null
+++ b/packages/frontend/src/utility/idle-render.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => {
+	const start = performance.now();
+	const timeoutId = setTimeout(() => {
+		callback({
+			didTimeout: false, // polyfill でタイムアウト発火することはない
+			timeRemaining() {
+				const diff = performance.now() - start;
+				return Math.max(0, 50 - diff); // <https://www.w3.org/TR/requestidlecallback/#idle-periods>
+			},
+		});
+	});
+	return timeoutId;
+});
+const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => {
+	clearTimeout(timeoutId);
+});
+
+class IdlingRenderScheduler {
+	#renderers: Set<FrameRequestCallback>;
+	#rafId: number;
+	#ricId: number;
+
+	constructor() {
+		this.#renderers = new Set();
+		this.#rafId = 0;
+		this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
+	}
+
+	#schedule(deadline: IdleDeadline): void {
+		if (deadline.timeRemaining()) {
+			this.#rafId = requestAnimationFrame((time) => {
+				for (const renderer of this.#renderers) {
+					renderer(time);
+				}
+			});
+		}
+		this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
+	}
+
+	add(renderer: FrameRequestCallback): void {
+		this.#renderers.add(renderer);
+	}
+
+	delete(renderer: FrameRequestCallback): void {
+		this.#renderers.delete(renderer);
+	}
+
+	dispose(): void {
+		this.#renderers.clear();
+		cancelAnimationFrame(this.#rafId);
+		cancelIdleCallback(this.#ricId);
+	}
+}
+
+export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();
diff --git a/packages/frontend/src/utility/init-chart.ts b/packages/frontend/src/utility/init-chart.ts
new file mode 100644
index 0000000000..037b0d9567
--- /dev/null
+++ b/packages/frontend/src/utility/init-chart.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	DoughnutController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
+import { store } from '@/store.js';
+import 'chartjs-adapter-date-fns';
+
+export function initChart() {
+	Chart.register(
+		ArcElement,
+		LineElement,
+		BarElement,
+		PointElement,
+		BarController,
+		LineController,
+		DoughnutController,
+		CategoryScale,
+		LinearScale,
+		TimeScale,
+		Legend,
+		Title,
+		Tooltip,
+		SubTitle,
+		Filler,
+		MatrixController, MatrixElement,
+		zoomPlugin,
+		gradient,
+	);
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
+
+	Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+	Chart.defaults.animation = false;
+}
diff --git a/packages/frontend/src/utility/initialize-sw.ts b/packages/frontend/src/utility/initialize-sw.ts
new file mode 100644
index 0000000000..867ebf19ed
--- /dev/null
+++ b/packages/frontend/src/utility/initialize-sw.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@@/js/config.js';
+
+export async function initializeSw() {
+	if (!('serviceWorker' in navigator)) return;
+
+	navigator.serviceWorker.register('/sw.js', { scope: '/', type: 'classic' });
+	navigator.serviceWorker.ready.then(registration => {
+		registration.active?.postMessage({
+			msg: 'initialize',
+			lang,
+		});
+	});
+}
diff --git a/packages/frontend/src/utility/intl-const.ts b/packages/frontend/src/utility/intl-const.ts
new file mode 100644
index 0000000000..385f59ec39
--- /dev/null
+++ b/packages/frontend/src/utility/intl-const.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@@/js/config.js';
+
+export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
+
+let _dateTimeFormat: Intl.DateTimeFormat;
+try {
+	_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
+		year: 'numeric',
+		month: 'numeric',
+		day: 'numeric',
+		hour: 'numeric',
+		minute: 'numeric',
+		second: 'numeric',
+	});
+} catch (err) {
+	console.warn(err);
+	if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+	// Fallback to en-US
+	_dateTimeFormat = new Intl.DateTimeFormat('en-US', {
+		year: 'numeric',
+		month: 'numeric',
+		day: 'numeric',
+		hour: 'numeric',
+		minute: 'numeric',
+		second: 'numeric',
+	});
+}
+export const dateTimeFormat = _dateTimeFormat;
+
+export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
+
+export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
+
+let _numberFormat: Intl.NumberFormat;
+try {
+	_numberFormat = new Intl.NumberFormat(versatileLang);
+} catch (err) {
+	console.warn(err);
+	if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+	// Fallback to en-US
+	_numberFormat = new Intl.NumberFormat('en-US');
+}
+export const numberFormat = _numberFormat;
diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts
new file mode 100644
index 0000000000..a5b5bbb592
--- /dev/null
+++ b/packages/frontend/src/utility/intl-string.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { versatileLang } from '@@/js/intl-const.js';
+import type { toHiragana as toHiraganaType } from 'wanakana';
+
+let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
+let isWanakanaLoaded = false;
+
+/** 
+ * ローマ字変換のセットアップ（日本語以外の環境で読み込まないのでlazy-loading）
+ * 
+ * ここの比較系関数を使う際は事前に呼び出す必要がある
+ */
+export async function initIntlString(forceWanakana = false) {
+	if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
+	const { toHiragana: _toHiragana } = await import('wanakana');
+	toHiragana = _toHiragana;
+	isWanakanaLoaded = true;
+}
+
+/**
+ * - 全角英数字を半角に
+ * - 半角カタカナを全角に
+ * - 濁点・半濁点がリガチャになっている（例: `か` ＋ `゛` ）ひらがな・カタカナを結合
+ * - 異体字を正規化
+ * - 小文字に揃える
+ * - 文字列のトリム
+ */
+export function normalizeString(str: string) {
+	const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
+	return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
+}
+
+// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
+const hyphens = [
+	0x002d, // hyphen-minus
+	0x02d7, // modifier letter minus sign
+	0x1173, // hangul jongseong eu
+	0x1680, // ogham space mark
+	0x1b78, // balinese musical symbol left-hand open pang
+	0x2010, // hyphen
+	0x2011, // non-breaking hyphen
+	0x2012, // figure dash
+	0x2013, // en dash
+	0x2014, // em dash
+	0x2015, // horizontal bar
+	0x2043, // hyphen bullet
+	0x207b, // superscript minus
+	0x2212, // minus sign
+	0x25ac, // black rectangle
+	0x2500, // box drawings light horizontal
+	0x2501, // box drawings heavy horizontal
+	0x2796, // heavy minus sign
+	0x30fc, // katakana-hiragana prolonged sound mark
+	0x3161, // hangul letter eu
+	0xfe58, // small em dash
+	0xfe63, // small hyphen-minus
+	0xff0d, // fullwidth hyphen-minus
+	0xff70, // halfwidth katakana-hiragana prolonged sound mark
+	0x10110, // aegean number ten
+	0x10191, // roman uncia sign
+];
+
+const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
+
+/** ハイフンを統一（ローマ字半角入力時に`ー`と`-`が判定できない問題の調整） */
+export function normalizeHyphens(str: string) {
+	return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
+}
+
+/**
+ * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
+ *
+ * （ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり）
+ */
+export function normalizeStringWithHiragana(str: string) {
+	return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
+}
+
+/** aとbが同じかどうか */
+export function compareStringEquals(a: string, b: string) {
+	return (
+		normalizeString(a) === normalizeString(b) ||
+		normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
+	);
+}
+
+/** baseにqueryが含まれているかどうか */
+export function compareStringIncludes(base: string, query: string) {
+	return (
+		normalizeString(base).includes(normalizeString(query)) ||
+		normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
+	);
+}
diff --git a/packages/frontend/src/utility/is-device-darkmode.ts b/packages/frontend/src/utility/is-device-darkmode.ts
new file mode 100644
index 0000000000..4f487c7cb9
--- /dev/null
+++ b/packages/frontend/src/utility/is-device-darkmode.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isDeviceDarkmode() {
+	return window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/packages/frontend/src/utility/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts
new file mode 100644
index 0000000000..e28e5725bc
--- /dev/null
+++ b/packages/frontend/src/utility/isFfVisibleForMe.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { $i } from '@/account.js';
+
+export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+	if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
+
+	if (user.followingVisibility === 'private') return false;
+	if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
+
+	return true;
+}
+export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+	if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
+
+	if (user.followersVisibility === 'private') return false;
+	if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
+
+	return true;
+}
diff --git a/packages/frontend/src/utility/key-event.ts b/packages/frontend/src/utility/key-event.ts
new file mode 100644
index 0000000000..020a6c2174
--- /dev/null
+++ b/packages/frontend/src/utility/key-event.ts
@@ -0,0 +1,153 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
+ */
+export type KeyCode = (
+	| 'Backspace'
+	| 'Tab'
+	| 'Enter'
+	| 'Shift'
+	| 'Control'
+	| 'Alt'
+	| 'Pause'
+	| 'CapsLock'
+	| 'Escape'
+	| 'Space'
+	| 'PageUp'
+	| 'PageDown'
+	| 'End'
+	| 'Home'
+	| 'ArrowLeft'
+	| 'ArrowUp'
+	| 'ArrowRight'
+	| 'ArrowDown'
+	| 'Insert'
+	| 'Delete'
+	| 'Digit0'
+	| 'Digit1'
+	| 'Digit2'
+	| 'Digit3'
+	| 'Digit4'
+	| 'Digit5'
+	| 'Digit6'
+	| 'Digit7'
+	| 'Digit8'
+	| 'Digit9'
+	| 'KeyA'
+	| 'KeyB'
+	| 'KeyC'
+	| 'KeyD'
+	| 'KeyE'
+	| 'KeyF'
+	| 'KeyG'
+	| 'KeyH'
+	| 'KeyI'
+	| 'KeyJ'
+	| 'KeyK'
+	| 'KeyL'
+	| 'KeyM'
+	| 'KeyN'
+	| 'KeyO'
+	| 'KeyP'
+	| 'KeyQ'
+	| 'KeyR'
+	| 'KeyS'
+	| 'KeyT'
+	| 'KeyU'
+	| 'KeyV'
+	| 'KeyW'
+	| 'KeyX'
+	| 'KeyY'
+	| 'KeyZ'
+	| 'MetaLeft'
+	| 'MetaRight'
+	| 'ContextMenu'
+	| 'F1'
+	| 'F2'
+	| 'F3'
+	| 'F4'
+	| 'F5'
+	| 'F6'
+	| 'F7'
+	| 'F8'
+	| 'F9'
+	| 'F10'
+	| 'F11'
+	| 'F12'
+	| 'NumLock'
+	| 'ScrollLock'
+	| 'Semicolon'
+	| 'Equal'
+	| 'Comma'
+	| 'Minus'
+	| 'Period'
+	| 'Slash'
+	| 'Backquote'
+	| 'BracketLeft'
+	| 'Backslash'
+	| 'BracketRight'
+	| 'Quote'
+	| 'Meta'
+	| 'AltGraph'
+);
+
+/**
+ * 修飾キーを表す文字列。不足分は適宜追加する。
+ */
+export type KeyModifier = (
+	| 'Shift'
+	| 'Control'
+	| 'Alt'
+	| 'Meta'
+);
+
+/**
+ * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
+ */
+export type KeyState = (
+	| 'composing'
+	| 'repeat'
+);
+
+export type KeyEventHandler = {
+	modifiers?: KeyModifier[];
+	states?: KeyState[];
+	code: KeyCode | 'any';
+	handler: (event: KeyboardEvent) => void;
+};
+
+export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
+	function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
+		if (modifiers) {
+			return modifiers.every(modifier => ev.getModifierState(modifier));
+		}
+		return true;
+	}
+
+	function checkState(ev: KeyboardEvent, states?: KeyState[]) {
+		if (states) {
+			return states.every(state => ev.getModifierState(state));
+		}
+		return true;
+	}
+
+	let hit = false;
+	for (const handler of handlers.filter(it => it.code === event.code)) {
+		if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
+			handler.handler(event);
+			hit = true;
+			break;
+		}
+	}
+
+	if (!hit) {
+		for (const handler of handlers.filter(it => it.code === 'any')) {
+			handler.handler(event);
+		}
+	}
+}
diff --git a/packages/frontend/src/utility/langmap.ts b/packages/frontend/src/utility/langmap.ts
new file mode 100644
index 0000000000..b32de15963
--- /dev/null
+++ b/packages/frontend/src/utility/langmap.ts
@@ -0,0 +1,671 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// TODO: sharedに置いてバックエンドのと統合したい
+export const langmap = {
+	'ach': {
+		nativeName: 'Lwo',
+	},
+	'ady': {
+		nativeName: 'Адыгэбзэ',
+	},
+	'af': {
+		nativeName: 'Afrikaans',
+	},
+	'af-NA': {
+		nativeName: 'Afrikaans (Namibia)',
+	},
+	'af-ZA': {
+		nativeName: 'Afrikaans (South Africa)',
+	},
+	'ak': {
+		nativeName: 'Tɕɥi',
+	},
+	'ar': {
+		nativeName: 'العربية',
+	},
+	'ar-AR': {
+		nativeName: 'العربية',
+	},
+	'ar-MA': {
+		nativeName: 'العربية',
+	},
+	'ar-SA': {
+		nativeName: 'العربية (السعودية)',
+	},
+	'ay-BO': {
+		nativeName: 'Aymar aru',
+	},
+	'az': {
+		nativeName: 'Azərbaycan dili',
+	},
+	'az-AZ': {
+		nativeName: 'Azərbaycan dili',
+	},
+	'be-BY': {
+		nativeName: 'Беларуская',
+	},
+	'bg': {
+		nativeName: 'Български',
+	},
+	'bg-BG': {
+		nativeName: 'Български',
+	},
+	'bn': {
+		nativeName: 'বাংলা',
+	},
+	'bn-IN': {
+		nativeName: 'বাংলা (ভারত)',
+	},
+	'bn-BD': {
+		nativeName: 'বাংলা(বাংলাদেশ)',
+	},
+	'br': {
+		nativeName: 'Brezhoneg',
+	},
+	'bs-BA': {
+		nativeName: 'Bosanski',
+	},
+	'ca': {
+		nativeName: 'Català',
+	},
+	'ca-ES': {
+		nativeName: 'Català',
+	},
+	'cak': {
+		nativeName: 'Maya Kaqchikel',
+	},
+	'ck-US': {
+		nativeName: 'ᏣᎳᎩ (tsalagi)',
+	},
+	'cs': {
+		nativeName: 'Čeština',
+	},
+	'cs-CZ': {
+		nativeName: 'Čeština',
+	},
+	'cy': {
+		nativeName: 'Cymraeg',
+	},
+	'cy-GB': {
+		nativeName: 'Cymraeg',
+	},
+	'da': {
+		nativeName: 'Dansk',
+	},
+	'da-DK': {
+		nativeName: 'Dansk',
+	},
+	'de': {
+		nativeName: 'Deutsch',
+	},
+	'de-AT': {
+		nativeName: 'Deutsch (Österreich)',
+	},
+	'de-DE': {
+		nativeName: 'Deutsch (Deutschland)',
+	},
+	'de-CH': {
+		nativeName: 'Deutsch (Schweiz)',
+	},
+	'dsb': {
+		nativeName: 'Dolnoserbšćina',
+	},
+	'el': {
+		nativeName: 'Ελληνικά',
+	},
+	'el-GR': {
+		nativeName: 'Ελληνικά',
+	},
+	'en': {
+		nativeName: 'English',
+	},
+	'en-GB': {
+		nativeName: 'English (UK)',
+	},
+	'en-AU': {
+		nativeName: 'English (Australia)',
+	},
+	'en-CA': {
+		nativeName: 'English (Canada)',
+	},
+	'en-IE': {
+		nativeName: 'English (Ireland)',
+	},
+	'en-IN': {
+		nativeName: 'English (India)',
+	},
+	'en-PI': {
+		nativeName: 'English (Pirate)',
+	},
+	'en-SG': {
+		nativeName: 'English (Singapore)',
+	},
+	'en-UD': {
+		nativeName: 'English (Upside Down)',
+	},
+	'en-US': {
+		nativeName: 'English (US)',
+	},
+	'en-ZA': {
+		nativeName: 'English (South Africa)',
+	},
+	'en@pirate': {
+		nativeName: 'English (Pirate)',
+	},
+	'eo': {
+		nativeName: 'Esperanto',
+	},
+	'eo-EO': {
+		nativeName: 'Esperanto',
+	},
+	'es': {
+		nativeName: 'Español',
+	},
+	'es-AR': {
+		nativeName: 'Español (Argentine)',
+	},
+	'es-419': {
+		nativeName: 'Español (Latinoamérica)',
+	},
+	'es-CL': {
+		nativeName: 'Español (Chile)',
+	},
+	'es-CO': {
+		nativeName: 'Español (Colombia)',
+	},
+	'es-EC': {
+		nativeName: 'Español (Ecuador)',
+	},
+	'es-ES': {
+		nativeName: 'Español (España)',
+	},
+	'es-LA': {
+		nativeName: 'Español (Latinoamérica)',
+	},
+	'es-NI': {
+		nativeName: 'Español (Nicaragua)',
+	},
+	'es-MX': {
+		nativeName: 'Español (México)',
+	},
+	'es-US': {
+		nativeName: 'Español (Estados Unidos)',
+	},
+	'es-VE': {
+		nativeName: 'Español (Venezuela)',
+	},
+	'et': {
+		nativeName: 'eesti keel',
+	},
+	'et-EE': {
+		nativeName: 'Eesti (Estonia)',
+	},
+	'eu': {
+		nativeName: 'Euskara',
+	},
+	'eu-ES': {
+		nativeName: 'Euskara',
+	},
+	'fa': {
+		nativeName: 'فارسی',
+	},
+	'fa-IR': {
+		nativeName: 'فارسی',
+	},
+	'fb-LT': {
+		nativeName: 'Leet Speak',
+	},
+	'ff': {
+		nativeName: 'Fulah',
+	},
+	'fi': {
+		nativeName: 'Suomi',
+	},
+	'fi-FI': {
+		nativeName: 'Suomi',
+	},
+	'fo': {
+		nativeName: 'Føroyskt',
+	},
+	'fo-FO': {
+		nativeName: 'Føroyskt (Færeyjar)',
+	},
+	'fr': {
+		nativeName: 'Français',
+	},
+	'fr-CA': {
+		nativeName: 'Français (Canada)',
+	},
+	'fr-FR': {
+		nativeName: 'Français (France)',
+	},
+	'fr-BE': {
+		nativeName: 'Français (Belgique)',
+	},
+	'fr-CH': {
+		nativeName: 'Français (Suisse)',
+	},
+	'fy-NL': {
+		nativeName: 'Frysk',
+	},
+	'ga': {
+		nativeName: 'Gaeilge',
+	},
+	'ga-IE': {
+		nativeName: 'Gaeilge',
+	},
+	'gd': {
+		nativeName: 'Gàidhlig',
+	},
+	'gl': {
+		nativeName: 'Galego',
+	},
+	'gl-ES': {
+		nativeName: 'Galego',
+	},
+	'gn-PY': {
+		nativeName: 'Avañe\'ẽ',
+	},
+	'gu-IN': {
+		nativeName: 'ગુજરાતી',
+	},
+	'gv': {
+		nativeName: 'Gaelg',
+	},
+	'gx-GR': {
+		nativeName: 'Ἑλληνική ἀρχαία',
+	},
+	'he': {
+		nativeName: 'עברית‏',
+	},
+	'he-IL': {
+		nativeName: 'עברית‏',
+	},
+	'hi': {
+		nativeName: 'हिन्दी',
+	},
+	'hi-IN': {
+		nativeName: 'हिन्दी',
+	},
+	'hr': {
+		nativeName: 'Hrvatski',
+	},
+	'hr-HR': {
+		nativeName: 'Hrvatski',
+	},
+	'hsb': {
+		nativeName: 'Hornjoserbšćina',
+	},
+	'ht': {
+		nativeName: 'Kreyòl',
+	},
+	'hu': {
+		nativeName: 'Magyar',
+	},
+	'hu-HU': {
+		nativeName: 'Magyar',
+	},
+	'hy': {
+		nativeName: 'Հայերեն',
+	},
+	'hy-AM': {
+		nativeName: 'Հայերեն (Հայաստան)',
+	},
+	'id': {
+		nativeName: 'Bahasa Indonesia',
+	},
+	'id-ID': {
+		nativeName: 'Bahasa Indonesia',
+	},
+	'is': {
+		nativeName: 'Íslenska',
+	},
+	'is-IS': {
+		nativeName: 'Íslenska (Iceland)',
+	},
+	'it': {
+		nativeName: 'Italiano',
+	},
+	'it-IT': {
+		nativeName: 'Italiano',
+	},
+	'ja': {
+		nativeName: '日本語',
+	},
+	'ja-JP': {
+		nativeName: '日本語 (日本)',
+	},
+	'jv-ID': {
+		nativeName: 'Basa Jawa',
+	},
+	'ka-GE': {
+		nativeName: 'ქართული',
+	},
+	'kk-KZ': {
+		nativeName: 'Қазақша',
+	},
+	'km': {
+		nativeName: 'ភាសាខ្មែរ',
+	},
+	'kl': {
+		nativeName: 'kalaallisut',
+	},
+	'km-KH': {
+		nativeName: 'ភាសាខ្មែរ',
+	},
+	'kab': {
+		nativeName: 'Taqbaylit',
+	},
+	'kn': {
+		nativeName: 'ಕನ್ನಡ',
+	},
+	'kn-IN': {
+		nativeName: 'ಕನ್ನಡ (India)',
+	},
+	'ko': {
+		nativeName: '한국어',
+	},
+	'ko-KR': {
+		nativeName: '한국어 (한국)',
+	},
+	'ku-TR': {
+		nativeName: 'Kurdî',
+	},
+	'kw': {
+		nativeName: 'Kernewek',
+	},
+	'la': {
+		nativeName: 'Latin',
+	},
+	'la-VA': {
+		nativeName: 'Latin',
+	},
+	'lb': {
+		nativeName: 'Lëtzebuergesch',
+	},
+	'li-NL': {
+		nativeName: 'Lèmbörgs',
+	},
+	'lt': {
+		nativeName: 'Lietuvių',
+	},
+	'lt-LT': {
+		nativeName: 'Lietuvių',
+	},
+	'lv': {
+		nativeName: 'Latviešu',
+	},
+	'lv-LV': {
+		nativeName: 'Latviešu',
+	},
+	'mai': {
+		nativeName: 'मैथिली, মৈথিলী',
+	},
+	'mg-MG': {
+		nativeName: 'Malagasy',
+	},
+	'mk': {
+		nativeName: 'Македонски',
+	},
+	'mk-MK': {
+		nativeName: 'Македонски (Македонски)',
+	},
+	'ml': {
+		nativeName: 'മലയാളം',
+	},
+	'ml-IN': {
+		nativeName: 'മലയാളം',
+	},
+	'mn-MN': {
+		nativeName: 'Монгол',
+	},
+	'mr': {
+		nativeName: 'मराठी',
+	},
+	'mr-IN': {
+		nativeName: 'मराठी',
+	},
+	'ms': {
+		nativeName: 'Bahasa Melayu',
+	},
+	'ms-MY': {
+		nativeName: 'Bahasa Melayu',
+	},
+	'mt': {
+		nativeName: 'Malti',
+	},
+	'mt-MT': {
+		nativeName: 'Malti',
+	},
+	'my': {
+		nativeName: 'ဗမာစကာ',
+	},
+	'no': {
+		nativeName: 'Norsk',
+	},
+	'nb': {
+		nativeName: 'Norsk (bokmål)',
+	},
+	'nb-NO': {
+		nativeName: 'Norsk (bokmål)',
+	},
+	'ne': {
+		nativeName: 'नेपाली',
+	},
+	'ne-NP': {
+		nativeName: 'नेपाली',
+	},
+	'nl': {
+		nativeName: 'Nederlands',
+	},
+	'nl-BE': {
+		nativeName: 'Nederlands (België)',
+	},
+	'nl-NL': {
+		nativeName: 'Nederlands (Nederland)',
+	},
+	'nn-NO': {
+		nativeName: 'Norsk (nynorsk)',
+	},
+	'oc': {
+		nativeName: 'Occitan',
+	},
+	'or-IN': {
+		nativeName: 'ଓଡ଼ିଆ',
+	},
+	'pa': {
+		nativeName: 'ਪੰਜਾਬੀ',
+	},
+	'pa-IN': {
+		nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
+	},
+	'pl': {
+		nativeName: 'Polski',
+	},
+	'pl-PL': {
+		nativeName: 'Polski',
+	},
+	'ps-AF': {
+		nativeName: 'پښتو',
+	},
+	'pt': {
+		nativeName: 'Português',
+	},
+	'pt-BR': {
+		nativeName: 'Português (Brasil)',
+	},
+	'pt-PT': {
+		nativeName: 'Português (Portugal)',
+	},
+	'qu-PE': {
+		nativeName: 'Qhichwa',
+	},
+	'rm-CH': {
+		nativeName: 'Rumantsch',
+	},
+	'ro': {
+		nativeName: 'Română',
+	},
+	'ro-RO': {
+		nativeName: 'Română',
+	},
+	'ru': {
+		nativeName: 'Русский',
+	},
+	'ru-RU': {
+		nativeName: 'Русский',
+	},
+	'sa-IN': {
+		nativeName: 'संस्कृतम्',
+	},
+	'se-NO': {
+		nativeName: 'Davvisámegiella',
+	},
+	'sh': {
+		nativeName: 'српскохрватски',
+	},
+	'si-LK': {
+		nativeName: 'සිංහල',
+	},
+	'sk': {
+		nativeName: 'Slovenčina',
+	},
+	'sk-SK': {
+		nativeName: 'Slovenčina (Slovakia)',
+	},
+	'sl': {
+		nativeName: 'Slovenščina',
+	},
+	'sl-SI': {
+		nativeName: 'Slovenščina',
+	},
+	'so-SO': {
+		nativeName: 'Soomaaliga',
+	},
+	'sq': {
+		nativeName: 'Shqip',
+	},
+	'sq-AL': {
+		nativeName: 'Shqip',
+	},
+	'sr': {
+		nativeName: 'Српски',
+	},
+	'sr-RS': {
+		nativeName: 'Српски (Serbia)',
+	},
+	'su': {
+		nativeName: 'Basa Sunda',
+	},
+	'sv': {
+		nativeName: 'Svenska',
+	},
+	'sv-SE': {
+		nativeName: 'Svenska',
+	},
+	'sw': {
+		nativeName: 'Kiswahili',
+	},
+	'sw-KE': {
+		nativeName: 'Kiswahili',
+	},
+	'ta': {
+		nativeName: 'தமிழ்',
+	},
+	'ta-IN': {
+		nativeName: 'தமிழ்',
+	},
+	'te': {
+		nativeName: 'తెలుగు',
+	},
+	'te-IN': {
+		nativeName: 'తెలుగు',
+	},
+	'tg': {
+		nativeName: 'забо́ни тоҷикӣ́',
+	},
+	'tg-TJ': {
+		nativeName: 'тоҷикӣ',
+	},
+	'th': {
+		nativeName: 'ภาษาไทย',
+	},
+	'th-TH': {
+		nativeName: 'ภาษาไทย (ประเทศไทย)',
+	},
+	'fil': {
+		nativeName: 'Filipino',
+	},
+	'tlh': {
+		nativeName: 'tlhIngan-Hol',
+	},
+	'tr': {
+		nativeName: 'Türkçe',
+	},
+	'tr-TR': {
+		nativeName: 'Türkçe',
+	},
+	'tt-RU': {
+		nativeName: 'татарча',
+	},
+	'uk': {
+		nativeName: 'Українська',
+	},
+	'uk-UA': {
+		nativeName: 'Українська',
+	},
+	'ur': {
+		nativeName: 'اردو',
+	},
+	'ur-PK': {
+		nativeName: 'اردو',
+	},
+	'uz': {
+		nativeName: 'O\'zbek',
+	},
+	'uz-UZ': {
+		nativeName: 'O\'zbek',
+	},
+	'vi': {
+		nativeName: 'Tiếng Việt',
+	},
+	'vi-VN': {
+		nativeName: 'Tiếng Việt',
+	},
+	'xh-ZA': {
+		nativeName: 'isiXhosa',
+	},
+	'yi': {
+		nativeName: 'ייִדיש',
+	},
+	'yi-DE': {
+		nativeName: 'ייִדיש (German)',
+	},
+	'zh': {
+		nativeName: '中文',
+	},
+	'zh-Hans': {
+		nativeName: '中文简体',
+	},
+	'zh-Hant': {
+		nativeName: '中文繁體',
+	},
+	'zh-CN': {
+		nativeName: '中文（中国大陆）',
+	},
+	'zh-HK': {
+		nativeName: '中文（香港）',
+	},
+	'zh-SG': {
+		nativeName: '中文（新加坡）',
+	},
+	'zh-TW': {
+		nativeName: '中文（台灣）',
+	},
+	'zu-ZA': {
+		nativeName: 'isiZulu',
+	},
+};
diff --git a/packages/frontend/src/utility/login-id.ts b/packages/frontend/src/utility/login-id.ts
new file mode 100644
index 0000000000..b52735caa0
--- /dev/null
+++ b/packages/frontend/src/utility/login-id.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function getUrlWithLoginId(url: string, loginId: string) {
+	const u = new URL(url, origin);
+	u.searchParams.append('loginId', loginId);
+	return u.toString();
+}
+
+export function getUrlWithoutLoginId(url: string) {
+	const u = new URL(url);
+	u.searchParams.delete('loginId');
+	return u.toString();
+}
diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts
new file mode 100644
index 0000000000..d3a2d854a0
--- /dev/null
+++ b/packages/frontend/src/utility/lookup.ts
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { Router } from '@/nirax.js';
+import { mainRouter } from '@/router/main.js';
+
+export async function lookup(router?: Router) {
+	const _router = router ?? mainRouter;
+
+	const { canceled, result: temp } = await os.inputText({
+		title: i18n.ts.lookup,
+	});
+	const query = temp ? temp.trim() : '';
+	if (canceled || query.length <= 1) return;
+
+	if (query.startsWith('@') && !query.includes(' ')) {
+		_router.push(`/${query}`);
+		return;
+	}
+
+	if (query.startsWith('#')) {
+		_router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
+		return;
+	}
+
+	if (query.startsWith('https://')) {
+		const res = await apLookup(query);
+
+		if (res.type === 'User') {
+			_router.push(`/@${res.object.username}@${res.object.host}`);
+		} else if (res.type === 'Note') {
+			_router.push(`/notes/${res.object.id}`);
+		}
+
+		return;
+	}
+}
+
+export async function apLookup(query: string) {
+	const promise = misskeyApi('ap/show', {
+		uri: query,
+	});
+
+	os.promiseDialog(promise, null, (err) => {
+		let title = i18n.ts.somethingHappened;
+		let text = err.message + '\n' + err.id;
+
+		switch (err.id) {
+			case '974b799e-1a29-4889-b706-18d4dd93e266':
+				title = i18n.ts._remoteLookupErrors._federationNotAllowed.title;
+				text = i18n.ts._remoteLookupErrors._federationNotAllowed.description;
+				break;
+			case '1a5eab56-e47b-48c2-8d5e-217b897d70db':
+				title = i18n.ts._remoteLookupErrors._uriInvalid.title;
+				text = i18n.ts._remoteLookupErrors._uriInvalid.description;
+				break;
+			case '81b539cf-4f57-4b29-bc98-032c33c0792e':
+				title = i18n.ts._remoteLookupErrors._requestFailed.title;
+				text = i18n.ts._remoteLookupErrors._requestFailed.description;
+				break;
+			case '70193c39-54f3-4813-82f0-70a680f7495b':
+				title = i18n.ts._remoteLookupErrors._responseInvalid.title;
+				text = i18n.ts._remoteLookupErrors._responseInvalid.description;
+				break;
+			case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
+				title = i18n.ts._remoteLookupErrors._noSuchObject.title;
+				text = i18n.ts._remoteLookupErrors._noSuchObject.description;
+				break;
+		}
+
+		os.alert({
+			type: 'error',
+			title,
+			text,
+		});
+	}, i18n.ts.fetchingAsApObject);
+
+	return await promise;
+}
diff --git a/packages/frontend/src/utility/media-has-audio.ts b/packages/frontend/src/utility/media-has-audio.ts
new file mode 100644
index 0000000000..4bf3ee5d97
--- /dev/null
+++ b/packages/frontend/src/utility/media-has-audio.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export default async function hasAudio(media: HTMLMediaElement) {
+	const cloned = media.cloneNode() as HTMLMediaElement;
+	cloned.muted = (cloned as typeof cloned & Partial<HTMLVideoElement>).playsInline = true;
+	cloned.play();
+	await new Promise((resolve) => cloned.addEventListener('playing', resolve));
+	const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount;
+	cloned.remove();
+	return result;
+}
diff --git a/packages/frontend/src/utility/media-proxy.ts b/packages/frontend/src/utility/media-proxy.ts
new file mode 100644
index 0000000000..78eba35ead
--- /dev/null
+++ b/packages/frontend/src/utility/media-proxy.ts
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { MediaProxy } from '@@/js/media-proxy.js';
+import { url } from '@@/js/config.js';
+import { instance } from '@/instance.js';
+
+let _mediaProxy: MediaProxy | null = null;
+
+export function getProxiedImageUrl(...args: Parameters<MediaProxy['getProxiedImageUrl']>): string {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
+	}
+
+	return _mediaProxy.getProxiedImageUrl(...args);
+}
+
+export function getProxiedImageUrlNullable(...args: Parameters<MediaProxy['getProxiedImageUrlNullable']>): string | null {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
+	}
+
+	return _mediaProxy.getProxiedImageUrlNullable(...args);
+}
+
+export function getStaticImageUrl(...args: Parameters<MediaProxy['getStaticImageUrl']>): string {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
+	}
+
+	return _mediaProxy.getStaticImageUrl(...args);
+}
diff --git a/packages/frontend/src/utility/merge.ts b/packages/frontend/src/utility/merge.ts
new file mode 100644
index 0000000000..004b6d42a4
--- /dev/null
+++ b/packages/frontend/src/utility/merge.ts
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { deepClone } from './clone.js';
+import type { Cloneable } from './clone.js';
+
+export type DeepPartial<T> = {
+	[P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P];
+};
+
+function isPureObject(value: unknown): value is Record<PropertyKey, unknown> {
+	return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * valueにないキーをdefからもらう（再帰的）\
+ * nullはそのまま、undefinedはdefの値
+ **/
+export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X {
+	if (isPureObject(value) && isPureObject(def)) {
+		const result = deepClone(value as Cloneable) as X;
+		for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
+			if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
+				result[k] = v;
+			} else if (isPureObject(v) && isPureObject(result[k])) {
+				const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>;
+				result[k] = deepMerge<typeof v>(child, v);
+			}
+		}
+		return result;
+	}
+	throw new Error('deepMerge: value and def must be pure objects');
+}
diff --git a/packages/frontend/src/utility/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts
new file mode 100644
index 0000000000..a2f777f623
--- /dev/null
+++ b/packages/frontend/src/utility/mfm-function-picker.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { nextTick } from 'vue';
+import type { Ref } from 'vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { MFM_TAGS } from '@@/js/const.js';
+import type { MenuItem } from '@/types/menu.js';
+
+/**
+ * MFMの装飾のリストを表示する
+ */
+export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
+	os.popupMenu([{
+		text: i18n.ts.addMfmFunction,
+		type: 'label',
+	}, ...getFunctionList(textArea, textRef)], src);
+}
+
+function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] {
+	return MFM_TAGS.map(tag => ({
+		text: tag,
+		icon: 'ti ti-icons',
+		action: () => add(textArea, textRef, tag),
+	}));
+}
+
+function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) {
+	const caretStart: number = textArea.selectionStart as number;
+	const caretEnd: number = textArea.selectionEnd as number;
+
+	MFM_TAGS.forEach(tag => {
+		if (type === tag) {
+			if (caretStart === caretEnd) {
+				// 単純にFunctionを追加
+				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
+				textRef.value = trimmedText;
+			} else {
+				// 選択範囲を囲むようにFunctionを追加
+				const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
+				textRef.value = trimmedText;
+			}
+		}
+	});
+
+	const nextCaretStart: number = caretStart + 3 + type.length;
+	const nextCaretEnd: number = caretEnd + 3 + type.length;
+
+	// キャレットを戻す
+	nextTick(() => {
+		textArea.focus();
+		textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
+	});
+}
diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts
new file mode 100644
index 0000000000..dc07ad477b
--- /dev/null
+++ b/packages/frontend/src/utility/misskey-api.ts
@@ -0,0 +1,116 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { ref } from 'vue';
+import { apiUrl } from '@@/js/config.js';
+import { $i } from '@/account.js';
+export const pendingApiRequestsCount = ref(0);
+
+export type Endpoint = keyof Misskey.Endpoints;
+
+export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req'];
+
+export type AnyRequest<E extends Endpoint | (string & unknown)> =
+	(E extends Endpoint ? Request<E> : never) | object;
+
+export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> =
+	E extends Endpoint
+	? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never
+	: object;
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApi<
+	ResT = void,
+	E extends Endpoint | NonNullable<string> = Endpoint,
+	P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never,
+	_ResT = ResT extends void ? Response<E, P> : ResT,
+>(
+	endpoint: E,
+	data: P & { i?: string | null; } = {} as any,
+	token?: string | null | undefined,
+	signal?: AbortSignal,
+): Promise<_ResT> {
+	if (endpoint.includes('://')) throw new Error('invalid endpoint');
+	pendingApiRequestsCount.value++;
+
+	const onFinally = () => {
+		pendingApiRequestsCount.value--;
+	};
+
+	const promise = new Promise<_ResT>((resolve, reject) => {
+		// Append a credential
+		if ($i) data.i = $i.token;
+		if (token !== undefined) data.i = token;
+
+		// Send request
+		window.fetch(`${apiUrl}/${endpoint}`, {
+			method: 'POST',
+			body: JSON.stringify(data),
+			credentials: 'omit',
+			cache: 'no-cache',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+			signal,
+		}).then(async (res) => {
+			const body = res.status === 204 ? null : await res.json();
+
+			if (res.status === 200) {
+				resolve(body);
+			} else if (res.status === 204) {
+				resolve(undefined as _ResT); // void -> undefined
+			} else {
+				reject(body.error);
+			}
+		}).catch(reject);
+	});
+
+	promise.then(onFinally, onFinally);
+
+	return promise;
+}
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApiGet<
+	ResT = void,
+	E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
+	P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
+	_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
+>(
+	endpoint: E,
+	data: P = {} as any,
+): Promise<_ResT> {
+	pendingApiRequestsCount.value++;
+
+	const onFinally = () => {
+		pendingApiRequestsCount.value--;
+	};
+
+	const query = new URLSearchParams(data as any);
+
+	const promise = new Promise<_ResT>((resolve, reject) => {
+		// Send request
+		window.fetch(`${apiUrl}/${endpoint}?${query}`, {
+			method: 'GET',
+			credentials: 'omit',
+			cache: 'default',
+		}).then(async (res) => {
+			const body = res.status === 204 ? null : await res.json();
+
+			if (res.status === 200) {
+				resolve(body);
+			} else if (res.status === 204) {
+				resolve(undefined as _ResT); // void -> undefined
+			} else {
+				reject(body.error);
+			}
+		}).catch(reject);
+	});
+
+	promise.then(onFinally, onFinally);
+
+	return promise;
+}
diff --git a/packages/frontend/src/utility/navigator.ts b/packages/frontend/src/utility/navigator.ts
new file mode 100644
index 0000000000..ffc0a457f4
--- /dev/null
+++ b/packages/frontend/src/utility/navigator.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isSupportShare(): boolean {
+	return 'share' in navigator;
+}
diff --git a/packages/frontend/src/utility/page-metadata.ts b/packages/frontend/src/utility/page-metadata.ts
new file mode 100644
index 0000000000..671751147c
--- /dev/null
+++ b/packages/frontend/src/utility/page-metadata.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue';
+import type { MaybeRefOrGetter, Ref } from 'vue';
+
+export type PageMetadata = {
+	title: string;
+	subtitle?: string;
+	icon?: string | null;
+	avatar?: Misskey.entities.User | null;
+	userName?: Misskey.entities.User | null;
+	needWideArea?: boolean;
+};
+
+type PageMetadataGetter = () => PageMetadata;
+type PageMetadataReceiver = (getter: PageMetadataGetter) => void;
+
+const RECEIVER_KEY = Symbol('ReceiverKey');
+const setReceiver = (v: PageMetadataReceiver): void => {
+	provide<PageMetadataReceiver>(RECEIVER_KEY, v);
+};
+const getReceiver = (): PageMetadataReceiver | undefined => {
+	return inject<PageMetadataReceiver>(RECEIVER_KEY);
+};
+
+const METADATA_KEY = Symbol('MetadataKey');
+const setMetadata = (v: Ref<PageMetadata | null>): void => {
+	provide<Ref<PageMetadata | null>>(METADATA_KEY, v);
+};
+const getMetadata = (): Ref<PageMetadata | null> | undefined => {
+	return inject<Ref<PageMetadata | null>>(METADATA_KEY);
+};
+
+export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => {
+	const metadataRef = ref(toValue(maybeRefOrGetterMetadata));
+	const metadataGetter = () => metadataRef.value;
+	const receiver = getReceiver();
+
+	// setup handler
+	receiver?.(metadataGetter);
+
+	// update handler
+	onBeforeUnmount(watch(
+		() => toValue(maybeRefOrGetterMetadata),
+		(metadata) => {
+			metadataRef.value = metadata;
+			receiver?.(metadataGetter);
+		},
+		{ deep: true },
+	));
+	onActivated(() => {
+		receiver?.(metadataGetter);
+	});
+};
+
+export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => {
+	setReceiver(receiver);
+};
+
+export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => {
+	setMetadata(metadataRef);
+};
+
+export const injectReactiveMetadata = (): Ref<PageMetadata | null> => {
+	const metadataRef = getMetadata();
+	return isRef(metadataRef) ? metadataRef : ref(null);
+};
diff --git a/packages/frontend/src/utility/physics.ts b/packages/frontend/src/utility/physics.ts
new file mode 100644
index 0000000000..8a4e9319b3
--- /dev/null
+++ b/packages/frontend/src/utility/physics.ts
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Matter from 'matter-js';
+
+export function physics(container: HTMLElement) {
+	const containerWidth = container.offsetWidth;
+	const containerHeight = container.offsetHeight;
+	const containerCenterX = containerWidth / 2;
+
+	// サイズ固定化(要らないかも？)
+	container.style.position = 'relative';
+	container.style.boxSizing = 'border-box';
+	container.style.width = `${containerWidth}px`;
+	container.style.height = `${containerHeight}px`;
+
+	// create engine
+	const engine = Matter.Engine.create({
+		constraintIterations: 4,
+		positionIterations: 8,
+		velocityIterations: 8,
+	});
+
+	const world = engine.world;
+
+	// create renderer
+	const render = Matter.Render.create({
+		engine: engine,
+		//element: document.getElementById('debug'),
+		options: {
+			width: containerWidth,
+			height: containerHeight,
+			background: 'transparent', // transparent to hide
+			wireframeBackground: 'transparent', // transparent to hide
+		},
+	});
+
+	// Disable to hide debug
+	Matter.Render.run(render);
+
+	// create runner
+	const runner = Matter.Runner.create();
+	Matter.Runner.run(runner, engine);
+
+	const groundThickness = 1024;
+	const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
+		isStatic: true,
+		restitution: 0.1,
+		friction: 2,
+	});
+
+	//const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+	//const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+
+	Matter.World.add(world, [
+		ground,
+		//wallRight,
+		//wallLeft,
+	]);
+
+	const objEls = Array.from(container.children) as HTMLElement[];
+	const objs: Matter.Body[] = [];
+	for (const objEl of objEls) {
+		const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
+		const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
+
+		let obj: Matter.Body;
+		if (objEl.classList.contains('_physics_circle_')) {
+			obj = Matter.Bodies.circle(
+				left + (objEl.offsetWidth / 2),
+				top + (objEl.offsetHeight / 2),
+				Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
+				{
+					restitution: 0.5,
+				},
+			);
+		} else {
+			const style = window.getComputedStyle(objEl);
+			obj = Matter.Bodies.rectangle(
+				left + (objEl.offsetWidth / 2),
+				top + (objEl.offsetHeight / 2),
+				objEl.offsetWidth,
+				objEl.offsetHeight,
+				{
+					chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
+					restitution: 0.5,
+				},
+			);
+		}
+		objEl.id = obj.id.toString();
+		objs.push(obj);
+	}
+
+	Matter.World.add(engine.world, objs);
+
+	// Add mouse control
+
+	const mouse = Matter.Mouse.create(container);
+	const mouseConstraint = Matter.MouseConstraint.create(engine, {
+		mouse: mouse,
+		constraint: {
+			stiffness: 0.1,
+			render: {
+				visible: false,
+			},
+		},
+	});
+
+	Matter.World.add(engine.world, mouseConstraint);
+
+	// keep the mouse in sync with rendering
+	render.mouse = mouse;
+
+	for (const objEl of objEls) {
+		objEl.style.position = 'absolute';
+		objEl.style.top = '0';
+		objEl.style.left = '0';
+		objEl.style.margin = '0';
+	}
+
+	window.requestAnimationFrame(update);
+
+	let stop = false;
+
+	function update() {
+		for (const objEl of objEls) {
+			const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
+			if (obj == null) continue;
+
+			const x = (obj.position.x - objEl.offsetWidth / 2);
+			const y = (obj.position.y - objEl.offsetHeight / 2);
+			const angle = obj.angle;
+			objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
+		}
+
+		if (!stop) {
+			window.requestAnimationFrame(update);
+		}
+	}
+
+	// 奈落に落ちたオブジェクトは消す
+	const intervalId = window.setInterval(() => {
+		for (const obj of objs) {
+			if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
+		}
+	}, 1000 * 10);
+
+	return {
+		stop: () => {
+			stop = true;
+			Matter.Runner.stop(runner);
+			window.clearInterval(intervalId);
+		},
+	};
+}
diff --git a/packages/frontend/src/utility/player-url-transform.ts b/packages/frontend/src/utility/player-url-transform.ts
new file mode 100644
index 0000000000..39c6df6500
--- /dev/null
+++ b/packages/frontend/src/utility/player-url-transform.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { hostname } from '@@/js/config.js';
+
+export function transformPlayerUrl(url: string): string {
+	const urlObj = new URL(url);
+	if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
+
+	const urlParams = new URLSearchParams(urlObj.search);
+
+	if (urlObj.hostname === 'player.twitch.tv') {
+		// TwitchはCSPの制約あり
+		// https://dev.twitch.tv/docs/embed/video-and-clips/
+		urlParams.set('parent', hostname);
+		urlParams.set('allowfullscreen', '');
+		urlParams.set('autoplay', 'true');
+	} else {
+		urlParams.set('autoplay', '1');
+		urlParams.set('auto_play', '1');
+	}
+	urlObj.search = urlParams.toString();
+
+	return urlObj.toString();
+}
diff --git a/packages/frontend/src/utility/please-login.ts b/packages/frontend/src/utility/please-login.ts
new file mode 100644
index 0000000000..a8a330eb6d
--- /dev/null
+++ b/packages/frontend/src/utility/please-login.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent } from 'vue';
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
+import { i18n } from '@/i18n.js';
+import { popup } from '@/os.js';
+
+export type OpenOnRemoteOptions = {
+	/**
+	 * 外部のMisskey Webで特定のパスを開く
+	 */
+	type: 'web';
+
+	/**
+	 * 内部パス（例: `/settings`）
+	 */
+	path: string;
+} | {
+	/**
+	 * 外部のMisskey Webで照会する
+	 */
+	type: 'lookup';
+
+	/**
+	 * 照会したいエンティティのURL
+	 *
+	 * （例: `https://misskey.example.com/notes/abcdexxxxyz`）
+	 */
+	url: string;
+} | {
+	/**
+	 * 外部のMisskeyでノートする
+	 */
+	type: 'share';
+
+	/**
+	 * `/share` ページに渡すクエリストリング
+	 *
+	 * @see https://go.misskey-hub.net/spec/share/
+	 */
+	params: Record<string, string>;
+};
+
+export function pleaseLogin(opts: {
+	path?: string;
+	message?: string;
+	openOnRemote?: OpenOnRemoteOptions;
+} = {}) {
+	if ($i) return;
+
+	let _openOnRemote: OpenOnRemoteOptions | undefined = undefined;
+
+	// 連合できる場合と、（連合ができなくても）共有する場合は外部連携オプションを設定
+	if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) {
+		_openOnRemote = opts.openOnRemote;
+	}
+
+	const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
+		autoSet: true,
+		message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
+		openOnRemote: _openOnRemote,
+	}, {
+		cancelled: () => {
+			if (opts.path) {
+				window.location.href = opts.path;
+			}
+		},
+		closed: () => dispose(),
+	});
+
+	throw new Error('signin required');
+}
diff --git a/packages/frontend/src/utility/popout.ts b/packages/frontend/src/utility/popout.ts
new file mode 100644
index 0000000000..5b141222e8
--- /dev/null
+++ b/packages/frontend/src/utility/popout.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { appendQuery } from '@@/js/url.js';
+import * as config from '@@/js/config.js';
+
+export function popout(path: string, w?: HTMLElement) {
+	let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
+	url = appendQuery(url, 'zen');
+	if (w) {
+		const position = w.getBoundingClientRect();
+		const width = parseInt(getComputedStyle(w, '').width, 10);
+		const height = parseInt(getComputedStyle(w, '').height, 10);
+		const x = window.screenX + position.left;
+		const y = window.screenY + position.top;
+		window.open(url, url,
+			`width=${width}, height=${height}, top=${y}, left=${x}`);
+	} else {
+		const width = 400;
+		const height = 500;
+		const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
+		const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
+		window.open(url, url,
+			`width=${width}, height=${height}, top=${x}, left=${y}`);
+	}
+}
diff --git a/packages/frontend/src/utility/popup-position.ts b/packages/frontend/src/utility/popup-position.ts
new file mode 100644
index 0000000000..3dad41a8b3
--- /dev/null
+++ b/packages/frontend/src/utility/popup-position.ts
@@ -0,0 +1,161 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function calcPopupPosition(el: HTMLElement, props: {
+	anchorElement?: HTMLElement | null;
+	innerMargin: number;
+	direction: 'top' | 'bottom' | 'left' | 'right';
+	align: 'top' | 'bottom' | 'left' | 'right' | 'center';
+	alignOffset?: number;
+	x?: number;
+	y?: number;
+}): { top: number; left: number; transformOrigin: string; } {
+	const contentWidth = el.offsetWidth;
+	const contentHeight = el.offsetHeight;
+
+	let rect: DOMRect;
+
+	if (props.anchorElement) {
+		rect = props.anchorElement.getBoundingClientRect();
+	}
+
+	const calcPosWhenTop = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
+		} else {
+			left = props.x;
+			top = (props.y - contentHeight) - props.innerMargin;
+		}
+
+		left -= (el.offsetWidth / 2);
+
+		if (left + contentWidth - window.scrollX > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.scrollX - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenBottom = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
+		} else {
+			left = props.x;
+			top = (props.y) + props.innerMargin;
+		}
+
+		left -= (el.offsetWidth / 2);
+
+		if (left + contentWidth - window.scrollX > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.scrollX - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenLeft = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
+			top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
+		} else {
+			left = (props.x - contentWidth) - props.innerMargin;
+			top = props.y;
+		}
+
+		top -= (el.offsetHeight / 2);
+
+		if (top + contentHeight - window.scrollY > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.scrollY - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calcPosWhenRight = () => {
+		let left: number;
+		let top: number;
+
+		if (props.anchorElement) {
+			left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
+
+			if (props.align === 'top') {
+				top = rect.top + window.scrollY;
+				if (props.alignOffset != null) top += props.alignOffset;
+			} else if (props.align === 'bottom') {
+				// TODO
+			} else { // center
+				top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
+				top -= (el.offsetHeight / 2);
+			}
+		} else {
+			left = props.x + props.innerMargin;
+			top = props.y;
+			top -= (el.offsetHeight / 2);
+		}
+
+		if (top + contentHeight - window.scrollY > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.scrollY - 1;
+		}
+
+		return [left, top];
+	};
+
+	const calc = (): {
+		left: number;
+		top: number;
+		transformOrigin: string;
+	} => {
+		switch (props.direction) {
+			case 'top': {
+				const [left, top] = calcPosWhenTop();
+
+				// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
+				if (top - window.scrollY < 0) {
+					const [left, top] = calcPosWhenBottom();
+					return { left, top, transformOrigin: 'center top' };
+				}
+
+				return { left, top, transformOrigin: 'center bottom' };
+			}
+
+			case 'bottom': {
+				const [left, top] = calcPosWhenBottom();
+				// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
+				return { left, top, transformOrigin: 'center top' };
+			}
+
+			case 'left': {
+				const [left, top] = calcPosWhenLeft();
+
+				// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
+				if (left - window.scrollX < 0) {
+					const [left, top] = calcPosWhenRight();
+					return { left, top, transformOrigin: 'left center' };
+				}
+
+				return { left, top, transformOrigin: 'right center' };
+			}
+
+			case 'right': {
+				const [left, top] = calcPosWhenRight();
+				// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
+				return { left, top, transformOrigin: 'left center' };
+			}
+		}
+	};
+
+	return calc();
+}
diff --git a/packages/frontend/src/utility/post-message.ts b/packages/frontend/src/utility/post-message.ts
new file mode 100644
index 0000000000..11b6f52ddd
--- /dev/null
+++ b/packages/frontend/src/utility/post-message.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const postMessageEventTypes = [
+	'misskey:shareForm:shareCompleted',
+] as const;
+
+export type PostMessageEventType = typeof postMessageEventTypes[number];
+
+export type MiPostMessageEvent = {
+	type: PostMessageEventType;
+	payload?: any;
+};
+
+/**
+ * 親フレームにイベントを送信
+ */
+export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
+	window.parent.postMessage({
+		type,
+		payload,
+	}, '*');
+}
diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts
new file mode 100644
index 0000000000..81f6c02dcf
--- /dev/null
+++ b/packages/frontend/src/utility/reaction-picker.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { defineAsyncComponent, ref } from 'vue';
+import type { Ref } from 'vue';
+import { popup } from '@/os.js';
+import { store } from '@/store.js';
+
+class ReactionPicker {
+	private src: Ref<HTMLElement | null> = ref(null);
+	private manualShowing = ref(false);
+	private targetNote: Ref<Misskey.entities.Note | null> = ref(null);
+	private onChosen?: (reaction: string) => void;
+	private onClosed?: () => void;
+
+	constructor() {
+		// nop
+	}
+
+	public async init() {
+		const reactionsRef = store.reactiveState.reactions;
+		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+			src: this.src,
+			pinnedEmojis: reactionsRef,
+			asReactionPicker: true,
+			targetNote: this.targetNote,
+			manualShowing: this.manualShowing,
+		}, {
+			done: reaction => {
+				if (this.onChosen) this.onChosen(reaction);
+			},
+			close: () => {
+				this.manualShowing.value = false;
+			},
+			closed: () => {
+				this.src.value = null;
+				if (this.onClosed) this.onClosed();
+			},
+		});
+	}
+
+	public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
+		this.src.value = src;
+		this.targetNote.value = targetNote;
+		this.manualShowing.value = true;
+		this.onChosen = onChosen;
+		this.onClosed = onClosed;
+	}
+}
+
+export const reactionPicker = new ReactionPicker();
diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts
new file mode 100644
index 0000000000..057f57471a
--- /dev/null
+++ b/packages/frontend/src/utility/reload-ask.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import { unisonReload } from '@/utility/unison-reload.js';
+
+let isReloadConfirming = false;
+
+export async function reloadAsk(opts: {
+	unison?: boolean;
+	reason?: string;
+}) {
+	if (isReloadConfirming) {
+		return;
+	}
+
+	isReloadConfirming = true;
+
+	const { canceled } = await os.confirm(opts.reason == null ? {
+		type: 'info',
+		text: i18n.ts.reloadConfirm,
+	} : {
+		type: 'info',
+		title: i18n.ts.reloadConfirm,
+		text: opts.reason,
+	}).finally(() => {
+		isReloadConfirming = false;
+	});
+
+	if (canceled) return;
+
+	if (opts.unison) {
+		unisonReload();
+	} else {
+		location.reload();
+	}
+}
diff --git a/packages/frontend/src/utility/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts
new file mode 100644
index 0000000000..371f69b9a7
--- /dev/null
+++ b/packages/frontend/src/utility/search-emoji.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type EmojiDef = {
+	emoji: string;
+	name: string;
+	url: string;
+	aliasOf?: string;
+} | {
+	emoji: string;
+	name: string;
+	aliasOf?: string;
+	isCustomEmoji?: true;
+};
+type EmojiScore = { emoji: EmojiDef, score: number };
+
+export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
+	if (!query) {
+		return [];
+	}
+
+	const matched = new Map<string, EmojiScore>();
+	// 完全一致（エイリアスなし）
+	emojiDb.some(x => {
+		if (x.name === query && !x.aliasOf) {
+			matched.set(x.name, { emoji: x, score: query.length + 3 });
+		}
+		return matched.size === max;
+	});
+
+	// 完全一致（エイリアス込み）
+	if (matched.size < max) {
+		emojiDb.some(x => {
+			if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
+				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
+			}
+			return matched.size === max;
+		});
+	}
+
+	// 前方一致（エイリアスなし）
+	if (matched.size < max) {
+		emojiDb.some(x => {
+			if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
+				matched.set(x.name, { emoji: x, score: query.length + 1 });
+			}
+			return matched.size === max;
+		});
+	}
+
+	// 前方一致（エイリアス込み）
+	if (matched.size < max) {
+		emojiDb.some(x => {
+			if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
+				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
+			}
+			return matched.size === max;
+		});
+	}
+
+	// 部分一致（エイリアス込み）
+	if (matched.size < max) {
+		emojiDb.some(x => {
+			if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
+				matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
+			}
+			return matched.size === max;
+		});
+	}
+
+	// 簡易あいまい検索（3文字以上）
+	if (matched.size < max && query.length > 3) {
+		const queryChars = [...query];
+		const hitEmojis = new Map<string, EmojiScore>();
+
+		for (const x of emojiDb) {
+			// 文字列の位置を進めながら、クエリの文字を順番に探す
+
+			let pos = 0;
+			let hit = 0;
+			for (const c of queryChars) {
+				pos = x.name.indexOf(c, pos);
+				if (pos <= -1) break;
+				hit++;
+			}
+
+			// 半分以上の文字が含まれていればヒットとする
+			if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
+				hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
+			}
+		}
+
+		// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく（6件＝オートコンプリートのポップアップのサイズ分）
+		[...hitEmojis.values()]
+			.sort((x, y) => y.score - x.score)
+			.slice(0, 6)
+			.forEach(it => matched.set(it.emoji.name, it));
+	}
+
+	return [...matched.values()]
+		.sort((x, y) => y.score - x.score)
+		.slice(0, max)
+		.map(it => it.emoji);
+}
diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts
new file mode 100644
index 0000000000..1bee4986f6
--- /dev/null
+++ b/packages/frontend/src/utility/select-file.ts
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { uploadFile } from '@/utility/upload.js';
+import { prefer } from '@/preferences.js';
+
+export function chooseFileFromPc(
+	multiple: boolean,
+	options?: {
+		uploadFolder?: string | null;
+		keepOriginal?: boolean;
+		nameConverter?: (file: File) => string | undefined;
+	},
+): Promise<Misskey.entities.DriveFile[]> {
+	const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
+	const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading;
+	const nameConverter = options?.nameConverter ?? (() => undefined);
+
+	return new Promise((res, rej) => {
+		const input = document.createElement('input');
+		input.type = 'file';
+		input.multiple = multiple;
+		input.onchange = () => {
+			if (!input.files) return res([]);
+			const promises = Array.from(
+				input.files,
+				file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
+			);
+
+			Promise.all(promises).then(driveFiles => {
+				res(driveFiles);
+			}).catch(err => {
+				// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
+			});
+
+			// 一応廃棄
+			(window as any).__misskey_input_ref__ = null;
+		};
+
+		// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+		// iOS Safari で正常に動かす為のおまじない
+		(window as any).__misskey_input_ref__ = input;
+
+		input.click();
+	});
+}
+
+export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
+	return new Promise((res, rej) => {
+		os.selectDriveFile(multiple).then(files => {
+			res(files);
+		});
+	});
+}
+
+export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
+	return new Promise((res, rej) => {
+		os.inputText({
+			title: i18n.ts.uploadFromUrl,
+			type: 'url',
+			placeholder: i18n.ts.uploadFromUrlDescription,
+		}).then(({ canceled, result: url }) => {
+			if (canceled) return;
+
+			const marker = Math.random().toString(); // TODO: UUIDとか使う
+
+			const connection = useStream().useChannel('main');
+			connection.on('urlUploadFinished', urlResponse => {
+				if (urlResponse.marker === marker) {
+					res(urlResponse.file);
+					connection.dispose();
+				}
+			});
+
+			misskeyApi('drive/files/upload-from-url', {
+				url: url,
+				folderId: prefer.s.uploadFolder,
+				marker,
+			});
+
+			os.alert({
+				title: i18n.ts.uploadFromUrlRequested,
+				text: i18n.ts.uploadFromUrlMayTakeTime,
+			});
+		});
+	});
+}
+
+function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
+	return new Promise((res, rej) => {
+		const keepOriginal = ref(prefer.s.keepOriginalUploading);
+
+		os.popupMenu([label ? {
+			text: label,
+			type: 'label',
+		} : undefined, {
+			type: 'switch',
+			text: i18n.ts.keepOriginalUploading,
+			ref: keepOriginal,
+		}, {
+			text: i18n.ts.upload,
+			icon: 'ti ti-upload',
+			action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
+		}, {
+			text: i18n.ts.fromDrive,
+			icon: 'ti ti-cloud',
+			action: () => chooseFileFromDrive(multiple).then(files => res(files)),
+		}, {
+			text: i18n.ts.fromUrl,
+			icon: 'ti ti-link',
+			action: () => chooseFileFromUrl().then(file => res([file])),
+		}], src);
+	});
+}
+
+export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
+	return select(src, label, false).then(files => files[0]);
+}
+
+export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
+	return select(src, label, true);
+}
diff --git a/packages/frontend/src/utility/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts
new file mode 100644
index 0000000000..35b3ef79d8
--- /dev/null
+++ b/packages/frontend/src/utility/show-moved-dialog.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+
+export function showMovedDialog() {
+	if (!$i) return;
+	if (!$i.movedTo) return;
+
+	os.alert({
+		type: 'error',
+		title: i18n.ts.accountMovedShort,
+		text: i18n.ts.operationForbidden,
+	});
+
+	throw new Error('account moved');
+}
diff --git a/packages/frontend/src/utility/show-suspended-dialog.ts b/packages/frontend/src/utility/show-suspended-dialog.ts
new file mode 100644
index 0000000000..8b89dbb936
--- /dev/null
+++ b/packages/frontend/src/utility/show-suspended-dialog.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+
+export function showSuspendedDialog() {
+	return os.alert({
+		type: 'error',
+		title: i18n.ts.yourAccountSuspendedTitle,
+		text: i18n.ts.yourAccountSuspendedDescription,
+	});
+}
diff --git a/packages/frontend/src/utility/shuffle.ts b/packages/frontend/src/utility/shuffle.ts
new file mode 100644
index 0000000000..1f6ef1928c
--- /dev/null
+++ b/packages/frontend/src/utility/shuffle.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * 配列をシャッフル (破壊的)
+ */
+export function shuffle<T extends unknown[]>(array: T): T {
+	let currentIndex = array.length;
+	let randomIndex: number;
+
+	// While there remain elements to shuffle.
+	while (currentIndex !== 0) {
+		// Pick a remaining element.
+		randomIndex = Math.floor(Math.random() * currentIndex);
+		currentIndex--;
+
+		// And swap it with the current element.
+		[array[currentIndex], array[randomIndex]] = [
+			array[randomIndex], array[currentIndex]];
+	}
+
+	return array;
+}
diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts
new file mode 100644
index 0000000000..d88bdb6660
--- /dev/null
+++ b/packages/frontend/src/utility/snowfall-effect.ts
@@ -0,0 +1,490 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SnowfallEffect {
+	private VERTEX_SOURCE = `#version 300 es
+		in vec4 a_position;
+		in vec4 a_color;
+		in vec3 a_rotation;
+		in vec3 a_speed;
+		in float a_size;
+		out vec4 v_color;
+		out float v_rotation;
+		uniform float u_time;
+		uniform mat4 u_projection;
+		uniform vec3 u_worldSize;
+		uniform float u_gravity;
+		uniform float u_wind;
+		uniform float u_spin_factor;
+		uniform float u_turbulence;
+
+		void main() {
+			v_color = a_color;
+			v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y;
+
+			vec3 pos = a_position.xyz;
+
+			pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x;
+			pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y;
+
+			pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z;
+			pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z;
+
+			gl_Position = u_projection * vec4(pos.xyz, a_position.w);
+			gl_PointSize = (a_size / gl_Position.w) * 100.0;
+		}
+	`;
+
+	private FRAGMENT_SOURCE = `#version 300 es
+		precision highp float;
+
+		in vec4 v_color;
+		in float v_rotation;
+		uniform sampler2D u_texture;
+		out vec4 out_color;
+
+		void main() {
+			vec2 rotated = vec2(
+				cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5,
+				cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5
+			);
+
+			vec4 snowflake = texture(u_texture, rotated);
+
+			out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a);
+		}
+	`;
+
+	private gl: WebGLRenderingContext;
+	private program: WebGLProgram;
+	private canvas: HTMLCanvasElement;
+	private buffers: Record<string, {
+		size: number;
+		value: number[] | Float32Array;
+		location: number;
+		ref: WebGLBuffer;
+	}>;
+	private uniforms: Record<string, {
+		type: string;
+		value: number[] | Float32Array;
+		location: WebGLUniformLocation;
+	}>;
+	private texture: WebGLTexture;
+	private camera: {
+		fov: number;
+		near: number;
+		far: number;
+		aspect: number;
+		z: number;
+	};
+	private wind: {
+		current: number;
+		force: number;
+		target: number;
+		min: number;
+		max: number;
+		easing: number;
+	};
+	private time: {
+		start: number;
+		previous: number;
+	} = {
+			start: 0,
+			previous: 0,
+		};
+	private raf = 0;
+
+	private density: number = 1 / 90;
+	private depth = 100;
+	private count = 1000;
+	private gravity = 100;
+	private speed: number = 1 / 10000;
+	private color: number[] = [1, 1, 1];
+	private opacity = 1;
+	private size = 4;
+	private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg==';
+	private mode = 'snow';
+
+	private INITIAL_BUFFERS = () => ({
+		position: { size: 3, value: [] },
+		color: { size: 4, value: [] },
+		size: { size: 1, value: [] },
+		rotation: { size: 3, value: [] },
+		speed: { size: 3, value: [] },
+	});
+
+	private INITIAL_UNIFORMS = () => ({
+		time: { type: 'float', value: 0 },
+		worldSize: { type: 'vec3', value: [0, 0, 0] },
+		gravity: { type: 'float', value: this.gravity },
+		wind: { type: 'float', value: 0 },
+		spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 },
+		turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 },
+		projection: {
+			type: 'mat4',
+			value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
+		},
+	});
+
+	private UNIFORM_SETTERS = {
+		int: 'uniform1i',
+		float: 'uniform1f',
+		vec2: 'uniform2fv',
+		vec3: 'uniform3fv',
+		vec4: 'uniform4fv',
+		mat2: 'uniformMatrix2fv',
+		mat3: 'uniformMatrix3fv',
+		mat4: 'uniformMatrix4fv',
+	};
+
+	private CAMERA = {
+		fov: 60,
+		near: 5,
+		far: 10000,
+		aspect: 1,
+		z: 100,
+	};
+
+	private WIND = {
+		current: 0,
+		force: 0.01,
+		target: 0.01,
+		min: 0,
+		max: 0.125,
+		easing: 0.0005,
+	};
+	/**
+	 * @throws {Error} - Thrown when it fails to get WebGL context for the canvas 
+	 */
+	constructor(options: {
+		sakura?: boolean;
+	}) {
+		if (options.sakura) {
+			this.mode = 'sakura';
+			this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC';
+			this.size = 10;
+			this.density = 1 / 280;
+		}
+
+		const canvas = this.initCanvas();
+		const gl = canvas.getContext('webgl2', { antialias: true });
+		if (gl == null) throw new Error('Failed to get WebGL context');
+
+		document.body.append(canvas);
+
+		this.canvas = canvas;
+		this.gl = gl;
+		this.program = this.initProgram();
+		this.buffers = this.initBuffers();
+		this.uniforms = this.initUniforms();
+		this.texture = this.initTexture();
+		this.camera = this.initCamera();
+		this.wind = this.initWind();
+
+		this.resize = this.resize.bind(this);
+		this.update = this.update.bind(this);
+
+		window.addEventListener('resize', () => this.resize());
+	}
+
+	private initCanvas(): HTMLCanvasElement {
+		const canvas = document.createElement('canvas');
+
+		Object.assign(canvas.style, {
+			position: 'fixed',
+			top: 0,
+			left: 0,
+			width: '100vw',
+			height: '100vh',
+			background: 'transparent',
+			'pointer-events': 'none',
+			'z-index': 2147483647,
+		});
+
+		return canvas;
+	}
+
+	private initCamera() {
+		return { ...this.CAMERA };
+	}
+
+	private initWind() {
+		return { ...this.WIND };
+	}
+
+	private initShader(type, source): WebGLShader {
+		const { gl } = this;
+		const shader = gl.createShader(type);
+		if (shader == null) throw new Error('Failed to create shader');
+
+		gl.shaderSource(shader, source);
+		gl.compileShader(shader);
+
+		return shader;
+	}
+
+	private initProgram(): WebGLProgram {
+		const { gl } = this;
+		const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE);
+		const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE);
+		const program = gl.createProgram();
+		if (program == null) throw new Error('Failed to create program');
+
+		gl.attachShader(program, vertex);
+		gl.attachShader(program, fragment);
+		gl.linkProgram(program);
+		gl.useProgram(program);
+
+		return program;
+	}
+
+	private initBuffers(): SnowfallEffect['buffers'] {
+		const { gl, program } = this;
+		const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers'];
+
+		for (const [name, buffer] of Object.entries(buffers)) {
+			buffer.location = gl.getAttribLocation(program, `a_${name}`);
+			buffer.ref = gl.createBuffer()!;
+
+			gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+			gl.enableVertexAttribArray(buffer.location);
+			gl.vertexAttribPointer(
+				buffer.location,
+				buffer.size,
+				gl.FLOAT,
+				false,
+				0,
+				0,
+			);
+		}
+
+		return buffers;
+	}
+
+	private updateBuffers() {
+		const { buffers } = this;
+
+		for (const name of Object.keys(buffers)) {
+			this.setBuffer(name);
+		}
+	}
+
+	private setBuffer(name: string, value?) {
+		const { gl, buffers } = this;
+		const buffer = buffers[name];
+
+		buffer.value = new Float32Array(value ?? buffer.value);
+
+		gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+		gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW);
+	}
+
+	private initUniforms(): SnowfallEffect['uniforms'] {
+		const { gl, program } = this;
+		const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms'];
+
+		for (const [name, uniform] of Object.entries(uniforms)) {
+			uniform.location = gl.getUniformLocation(program, `u_${name}`)!;
+		}
+
+		return uniforms;
+	}
+
+	private updateUniforms() {
+		const { uniforms } = this;
+
+		for (const name of Object.keys(uniforms)) {
+			this.setUniform(name);
+		}
+	}
+
+	private setUniform(name: string, value?) {
+		const { gl, uniforms } = this;
+		const uniform = uniforms[name];
+		const setter = this.UNIFORM_SETTERS[uniform.type];
+		const isMatrix = /^mat[2-4]$/i.test(uniform.type);
+
+		uniform.value = value ?? uniform.value;
+
+		if (isMatrix) {
+			gl[setter](uniform.location, false, uniform.value);
+		} else {
+			gl[setter](uniform.location, uniform.value);
+		}
+	}
+
+	private initTexture() {
+		const { gl } = this;
+		const texture = gl.createTexture();
+		if (texture == null) throw new Error('Failed to create texture');
+		const image = new Image();
+
+		gl.bindTexture(gl.TEXTURE_2D, texture);
+		gl.texImage2D(
+			gl.TEXTURE_2D,
+			0,
+			gl.RGBA,
+			1,
+			1,
+			0,
+			gl.RGBA,
+			gl.UNSIGNED_BYTE,
+			new Uint8Array([0, 0, 0, 0]),
+		);
+
+		image.onload = () => {
+			gl.bindTexture(gl.TEXTURE_2D, texture);
+			gl.texImage2D(
+				gl.TEXTURE_2D,
+				0,
+				gl.RGBA,
+				gl.RGBA,
+				gl.UNSIGNED_BYTE,
+				image,
+			);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+		};
+
+		image.src = this.snowflake;
+
+		return texture;
+	}
+
+	private initSnowflakes(vw: number, vh: number, dpi: number) {
+		const position: number[] = [];
+		const color: number[] = [];
+		const size: number[] = [];
+		const rotation: number[] = [];
+		const speed: number[] = [];
+
+		const height = 1 / this.density;
+		const width = (vw / vh) * height;
+		const depth = this.depth;
+		const count = this.count;
+		const length = (vw / vh) * count;
+
+		for (let i = 0; i < length; ++i) {
+			position.push(
+				-width + Math.random() * width * 2,
+				-height + Math.random() * height * 2,
+				Math.random() * depth * 2,
+			);
+
+			speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10);
+
+			rotation.push(
+				Math.random() * 2 * Math.PI,
+				Math.random() * 20,
+				Math.random() * 10,
+			);
+
+			color.push(...this.color, 0.1 + Math.random() * this.opacity);
+			//size.push((this.size * Math.random() * this.size * vh * dpi) / 1000);
+			size.push((this.size * vh * dpi) / 1000);
+		}
+
+		this.setUniform('worldSize', [width, height, depth]);
+
+		this.setBuffer('position', position);
+		this.setBuffer('color', color);
+		this.setBuffer('rotation', rotation);
+		this.setBuffer('size', size);
+		this.setBuffer('speed', speed);
+	}
+
+	private setProjection(aspect: number) {
+		const { camera } = this;
+
+		camera.aspect = aspect;
+
+		const fovRad = (camera.fov * Math.PI) / 180;
+		const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad);
+		const rangeInv = 1.0 / (camera.near - camera.far);
+
+		const m0 = f / camera.aspect;
+		const m5 = f;
+		const m10 = (camera.near + camera.far) * rangeInv;
+		const m11 = -1;
+		const m14 = camera.near * camera.far * rangeInv * 2 + camera.z;
+		const m15 = camera.z;
+
+		return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15];
+	}
+
+	public render() {
+		const { gl } = this;
+
+		gl.enable(gl.BLEND);
+		gl.enable(gl.CULL_FACE);
+		gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
+		gl.disable(gl.DEPTH_TEST);
+
+		this.updateBuffers();
+		this.updateUniforms();
+		this.resize(true);
+
+		this.time = {
+			start: window.performance.now(),
+			previous: window.performance.now(),
+		};
+
+		if (this.raf) window.cancelAnimationFrame(this.raf);
+		this.raf = window.requestAnimationFrame(this.update);
+
+		return this;
+	}
+
+	private resize(updateSnowflakes = false) {
+		const { canvas, gl } = this;
+		const vw = canvas.offsetWidth;
+		const vh = canvas.offsetHeight;
+		const aspect = vw / vh;
+		const dpi = window.devicePixelRatio;
+
+		canvas.width = vw * dpi;
+		canvas.height = vh * dpi;
+
+		gl.viewport(0, 0, vw * dpi, vh * dpi);
+		gl.clearColor(0, 0, 0, 0);
+
+		if (updateSnowflakes === true) {
+			this.initSnowflakes(vw, vh, dpi);
+		}
+
+		this.setUniform('projection', this.setProjection(aspect));
+	}
+
+	private update(timestamp: number) {
+		const { gl, buffers, wind } = this;
+		const elapsed = (timestamp - this.time.start) * this.speed;
+		const delta = timestamp - this.time.previous;
+
+		gl.clear(gl.COLOR_BUFFER_BIT);
+		gl.drawArrays(
+			gl.POINTS,
+			0,
+			buffers.position.value.length / buffers.position.size,
+		);
+
+		if (Math.random() > 0.995) {
+			wind.target =
+				(wind.min + Math.random() * (wind.max - wind.min)) *
+				(Math.random() > 0.5 ? -1 : 1);
+		}
+
+		wind.force += (wind.target - wind.force) * wind.easing;
+		wind.current += wind.force * (delta * 0.2);
+
+		this.setUniform('wind', wind.current);
+		this.setUniform('time', elapsed);
+
+		this.time.previous = timestamp;
+
+		this.raf = window.requestAnimationFrame(this.update);
+	}
+}
diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts
new file mode 100644
index 0000000000..436c2b75f0
--- /dev/null
+++ b/packages/frontend/src/utility/sound.ts
@@ -0,0 +1,258 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { SoundStore } from '@/preferences/def.js';
+import { prefer } from '@/preferences.js';
+import { PREF_DEF } from '@/preferences/def.js';
+
+let ctx: AudioContext;
+const cache = new Map<string, AudioBuffer>();
+let canPlay = true;
+
+export const soundsTypes = [
+	// 音声なし
+	null,
+
+	// ドライブの音声
+	'_driveFile_',
+
+	// プリインストール
+	'syuilo/n-aec',
+	'syuilo/n-aec-4va',
+	'syuilo/n-aec-4vb',
+	'syuilo/n-aec-8va',
+	'syuilo/n-aec-8vb',
+	'syuilo/n-cea',
+	'syuilo/n-cea-4va',
+	'syuilo/n-cea-4vb',
+	'syuilo/n-cea-8va',
+	'syuilo/n-cea-8vb',
+	'syuilo/n-eca',
+	'syuilo/n-eca-4va',
+	'syuilo/n-eca-4vb',
+	'syuilo/n-eca-8va',
+	'syuilo/n-eca-8vb',
+	'syuilo/n-ea',
+	'syuilo/n-ea-4va',
+	'syuilo/n-ea-4vb',
+	'syuilo/n-ea-8va',
+	'syuilo/n-ea-8vb',
+	'syuilo/n-ea-harmony',
+	'syuilo/up',
+	'syuilo/down',
+	'syuilo/pope1',
+	'syuilo/pope2',
+	'syuilo/waon',
+	'syuilo/popo',
+	'syuilo/triple',
+	'syuilo/bubble1',
+	'syuilo/bubble2',
+	'syuilo/poi1',
+	'syuilo/poi2',
+	'syuilo/pirori',
+	'syuilo/pirori-wet',
+	'syuilo/pirori-square-wet',
+	'syuilo/square-pico',
+	'syuilo/reverved',
+	'syuilo/ryukyu',
+	'syuilo/kick',
+	'syuilo/snare',
+	'syuilo/queue-jammed',
+	'aisha/1',
+	'aisha/2',
+	'aisha/3',
+	'noizenecio/kick_gaba1',
+	'noizenecio/kick_gaba2',
+	'noizenecio/kick_gaba3',
+	'noizenecio/kick_gaba4',
+	'noizenecio/kick_gaba5',
+	'noizenecio/kick_gaba6',
+	'noizenecio/kick_gaba7',
+] as const;
+
+export const operationTypes = [
+	'noteMy',
+	'note',
+	'notification',
+	'reaction',
+] as const;
+
+/** サウンドの種類 */
+export type SoundType = typeof soundsTypes[number];
+
+/** スプライトの種類 */
+export type OperationType = typeof operationTypes[number];
+
+/**
+ * 音声を読み込む
+ * @param url url
+ * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
+ */
+export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	if (ctx == null) {
+		ctx = new AudioContext();
+
+		window.addEventListener('beforeunload', () => {
+			ctx.close();
+		});
+	}
+	if (options?.useCache ?? true) {
+		if (cache.has(url)) {
+			return cache.get(url) as AudioBuffer;
+		}
+	}
+
+	let response: Response;
+
+	try {
+		response = await fetch(url);
+	} catch (err) {
+		return;
+	}
+
+	const arrayBuffer = await response.arrayBuffer();
+	const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
+
+	if (options?.useCache ?? true) {
+		cache.set(url, audioBuffer);
+	}
+
+	return audioBuffer;
+}
+
+/**
+ * 既定のスプライトを再生する
+ * @param type スプライトの種類を指定
+ */
+export function playMisskeySfx(operationType: OperationType) {
+	const sound = prefer.s[`sound.on.${operationType}`];
+	playMisskeySfxFile(sound).then((succeed) => {
+		if (!succeed && sound.type === '_driveFile_') {
+			// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
+			const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
+			if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
+			playMisskeySfxFileInternal({
+				type: soundName,
+				volume: sound.volume,
+			});
+		}
+	});
+}
+
+/**
+ * サウンド設定形式で指定された音声を再生する
+ * @param soundStore サウンド設定
+ */
+export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolean> {
+	// 連続して再生しない
+	if (!canPlay) return false;
+	// ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
+	if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
+	// サウンドがない場合は再生しない
+	if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
+
+	canPlay = false;
+	return await playMisskeySfxFileInternal(soundStore).finally(() => {
+		// ごく短時間に音が重複しないように
+		setTimeout(() => {
+			canPlay = true;
+		}, 25);
+	});
+}
+
+async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boolean> {
+	if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
+		return false;
+	}
+	const masterVolume = prefer.s['sound.masterVolume'];
+	if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
+		return true; // ミュート時は成功として扱う
+	}
+	const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
+	const buffer = await loadAudio(url).catch(() => {
+		return undefined;
+	});
+	if (!buffer) return false;
+	const volume = soundStore.volume * masterVolume;
+	createSourceNode(buffer, { volume }).soundSource.start();
+	return true;
+}
+
+export async function playUrl(url: string, opts: {
+	volume?: number;
+	pan?: number;
+	playbackRate?: number;
+}) {
+	if (opts.volume === 0) {
+		return;
+	}
+	const buffer = await loadAudio(url);
+	if (!buffer) return;
+	createSourceNode(buffer, opts).soundSource.start();
+}
+
+export function createSourceNode(buffer: AudioBuffer, opts: {
+	volume?: number;
+	pan?: number;
+	playbackRate?: number;
+}): {
+		soundSource: AudioBufferSourceNode;
+		panNode: StereoPannerNode;
+		gainNode: GainNode;
+	} {
+	const panNode = ctx.createStereoPanner();
+	panNode.pan.value = opts.pan ?? 0;
+
+	const gainNode = ctx.createGain();
+
+	gainNode.gain.value = opts.volume ?? 1;
+
+	const soundSource = ctx.createBufferSource();
+	soundSource.buffer = buffer;
+	soundSource.playbackRate.value = opts.playbackRate ?? 1;
+	soundSource
+		.connect(panNode)
+		.connect(gainNode)
+		.connect(ctx.destination);
+
+	return { soundSource, panNode, gainNode };
+}
+
+/**
+ * 音声の長さをミリ秒で取得する
+ * @param file ファイルのURL（ドライブIDではない）
+ */
+export async function getSoundDuration(file: string): Promise<number> {
+	const audioEl = document.createElement('audio');
+	audioEl.src = file;
+	return new Promise((resolve) => {
+		const si = setInterval(() => {
+			if (audioEl.readyState > 0) {
+				resolve(audioEl.duration * 1000);
+				clearInterval(si);
+				audioEl.remove();
+			}
+		}, 100);
+	});
+}
+
+/**
+ * ミュートすべきかどうかを判断する
+ */
+export function isMute(): boolean {
+	if (prefer.s['sound.notUseSound']) {
+		// サウンドを出力しない
+		return true;
+	}
+
+	// noinspection RedundantIfStatementJS
+	if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
+		// ブラウザがアクティブな時のみサウンドを出力する
+		return true;
+	}
+
+	return false;
+}
diff --git a/packages/frontend/src/utility/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts
new file mode 100644
index 0000000000..50f1e6ecc8
--- /dev/null
+++ b/packages/frontend/src/utility/sticky-sidebar.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class StickySidebar {
+	private lastScrollTop = 0;
+	private container: HTMLElement;
+	private el: HTMLElement;
+	private spacer: HTMLElement;
+	private marginTop: number;
+	private isTop = false;
+	private isBottom = false;
+	private offsetTop: number;
+	private globalHeaderHeight = 59;
+
+	constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
+		this.container = container;
+		this.el = this.container.children[0] as HTMLElement;
+		this.el.style.position = 'sticky';
+		this.spacer = document.createElement('div');
+		this.container.prepend(this.spacer);
+		this.marginTop = marginTop;
+		this.offsetTop = this.container.getBoundingClientRect().top;
+		this.globalHeaderHeight = globalHeaderHeight;
+	}
+
+	public calc(scrollTop: number) {
+		if (scrollTop > this.lastScrollTop) { // downscroll
+			const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
+			this.el.style.bottom = null;
+			this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
+
+			this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
+
+			if (this.isTop) {
+				this.isTop = false;
+				this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
+			}
+		} else { // upscroll
+			const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
+			this.el.style.top = null;
+			this.el.style.bottom = `${-overflow}px`;
+
+			this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
+
+			if (this.isBottom) {
+				this.isBottom = false;
+				this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
+			}
+		}
+
+		this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
+	}
+}
diff --git a/packages/frontend/src/utility/stream-mock.ts b/packages/frontend/src/utility/stream-mock.ts
new file mode 100644
index 0000000000..9b1b368de4
--- /dev/null
+++ b/packages/frontend/src/utility/stream-mock.ts
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import * as Misskey from 'misskey-js';
+import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
+
+type AnyOf<T extends Record<any, any>> = T[keyof T];
+type OmitFirst<T extends any[]> = T extends [any, ...infer R] ? R : never;
+
+/**
+ * Websocket無効化時に使うStreamのモック（なにもしない）
+ */
+export class StreamMock extends EventEmitter<StreamEvents> implements IStream {
+	public readonly state = 'initializing';
+
+	constructor(...args: ConstructorParameters<typeof Misskey.Stream>) {
+		super();
+		// do nothing
+	}
+
+	public useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock<Channels[C]> {
+		return new ChannelConnectionMock(this, channel, name);
+	}
+
+	public removeSharedConnection(connection: any): void {
+		// do nothing
+	}
+
+	public removeSharedConnectionPool(pool: any): void {
+		// do nothing
+	}
+
+	public disconnectToChannel(): void {
+		// do nothing
+	}
+
+	public send(typeOrPayload: string): void;
+	public send(typeOrPayload: string, payload: any): void;
+	public send(typeOrPayload: Record<string, any> | any[]): void;
+	public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void {
+		// do nothing
+	}
+
+	public ping(): void {
+		// do nothing
+	}
+
+	public heartbeat(): void {
+		// do nothing
+	}
+
+	public close(): void {
+		// do nothing
+	}
+}
+
+class ChannelConnectionMock<Channel extends AnyOf<Channels> = any> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> {
+	public id = '';
+	public name?: string; // for debug
+	public inCount = 0; // for debug
+	public outCount = 0; // for debug
+	public channel: string;
+
+	constructor(stream: IStream, ...args: OmitFirst<ConstructorParameters<typeof Misskey.ChannelConnection<Channel>>>) {
+		super();
+
+		this.channel = args[0];
+		this.name = args[1];
+	}
+
+	public send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void {
+		// do nothing
+	}
+
+	public dispose(): void {
+		// do nothing
+	}
+}
diff --git a/packages/frontend/src/utility/test-utils.ts b/packages/frontend/src/utility/test-utils.ts
new file mode 100644
index 0000000000..52bb2d94e0
--- /dev/null
+++ b/packages/frontend/src/utility/test-utils.ts
@@ -0,0 +1,9 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export async function tick(): Promise<void> {
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
+}
diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts
new file mode 100644
index 0000000000..0206e378bf
--- /dev/null
+++ b/packages/frontend/src/utility/theme-editor.ts
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { v4 as uuid } from 'uuid';
+
+import { themeProps } from './theme.js';
+import type { Theme } from './theme.js';
+
+export type Default = null;
+export type Color = string;
+export type FuncName = 'alpha' | 'darken' | 'lighten';
+export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
+export type RefProp = { type: 'refProp'; key: string; };
+export type RefConst = { type: 'refConst'; key: string; };
+export type Css = { type: 'css'; value: string; };
+
+export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
+
+export type ThemeViewModel = [ string, ThemeValue ][];
+
+export const fromThemeString = (str?: string) : ThemeValue => {
+	if (!str) return null;
+	if (str.startsWith(':')) {
+		const parts = str.slice(1).split('<');
+		const name = parts[0] as FuncName;
+		const arg = parseFloat(parts[1]);
+		const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
+		return { type: 'func', name, arg, value };
+	} else if (str.startsWith('@')) {
+		return {
+			type: 'refProp',
+			key: str.slice(1),
+		};
+	} else if (str.startsWith('$')) {
+		return {
+			type: 'refConst',
+			key: str.slice(1),
+		};
+	} else if (str.startsWith('"')) {
+		return {
+			type: 'css',
+			value: str.substring(1).trim(),
+		};
+	} else {
+		return str;
+	}
+};
+
+export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
+	if (typeof value === 'string') return value;
+	switch (value.type) {
+		case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
+		case 'refProp': return `@${value.key}`;
+		case 'refConst': return `$${value.key}`;
+		case 'css': return `" ${value.value}`;
+	}
+};
+
+export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
+	const props = { } as { [key: string]: string };
+	for (const [key, value] of vm) {
+		if (value === null) continue;
+		props[key] = toThemeString(value);
+	}
+
+	return {
+		id: uuid(),
+		name, desc, author, props, base,
+	};
+};
+
+export const convertToViewModel = (theme: Theme): ThemeViewModel => {
+	const vm: ThemeViewModel = [];
+	// プロパティの登録
+	vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
+
+	// 定数の登録
+	const consts = Object
+		.keys(theme.props)
+		.filter(k => k.startsWith('$'))
+		.map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
+
+	vm.push(...consts);
+	return vm;
+};
diff --git a/packages/frontend/src/utility/theme.ts b/packages/frontend/src/utility/theme.ts
new file mode 100644
index 0000000000..851ba41e61
--- /dev/null
+++ b/packages/frontend/src/utility/theme.ts
@@ -0,0 +1,189 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
+import JSON5 from 'json5';
+import { deepClone } from './clone.js';
+import type { BundledTheme } from 'shiki/themes';
+import { globalEvents } from '@/events.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { addTheme, getThemes } from '@/theme-store.js';
+
+export type Theme = {
+	id: string;
+	name: string;
+	author: string;
+	desc?: string;
+	base?: 'dark' | 'light';
+	props: Record<string, string>;
+	codeHighlighter?: {
+		base: BundledTheme;
+		overrides?: Record<string, any>;
+	} | {
+		base: '_none_';
+		overrides: Record<string, any>;
+	};
+};
+
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
+export const getBuiltinThemes = () => Promise.all(
+	[
+		'l-light',
+		'l-coffee',
+		'l-apricot',
+		'l-rainy',
+		'l-botanical',
+		'l-vivid',
+		'l-cherry',
+		'l-sushi',
+		'l-u0',
+
+		'd-dark',
+		'd-persimmon',
+		'd-astro',
+		'd-future',
+		'd-botanical',
+		'd-green-lime',
+		'd-green-orange',
+		'd-cherry',
+		'd-ice',
+		'd-u0',
+	].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
+);
+
+export const getBuiltinThemesRef = () => {
+	const builtinThemes = ref<Theme[]>([]);
+	getBuiltinThemes().then(themes => builtinThemes.value = themes);
+	return builtinThemes;
+};
+
+let timeout: number | null = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+	if (timeout) window.clearTimeout(timeout);
+
+	document.documentElement.classList.add('_themeChanging_');
+
+	timeout = window.setTimeout(() => {
+		document.documentElement.classList.remove('_themeChanging_');
+	}, 1000);
+
+	const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
+
+	document.documentElement.dataset.colorScheme = colorScheme;
+
+	// Deep copy
+	const _theme = deepClone(theme);
+
+	if (_theme.base) {
+		const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
+		if (base) _theme.props = Object.assign({}, base.props, _theme.props);
+	}
+
+	const props = compile(_theme);
+
+	for (const tag of document.head.children) {
+		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
+			tag.setAttribute('content', props['htmlThemeColor']);
+			break;
+		}
+	}
+
+	for (const [k, v] of Object.entries(props)) {
+		document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
+	}
+
+	document.documentElement.style.setProperty('color-scheme', colorScheme);
+
+	if (persist) {
+		miLocalStorage.setItem('theme', JSON.stringify(props));
+		miLocalStorage.setItem('themeId', theme.id);
+		miLocalStorage.setItem('colorScheme', colorScheme);
+	}
+
+	// 色計算など再度行えるようにクライアント全体に通知
+	globalEvents.emit('themeChanged');
+}
+
+function compile(theme: Theme): Record<string, string> {
+	function getColor(val: string): tinycolor.Instance {
+		if (val[0] === '@') { // ref (prop)
+			return getColor(theme.props[val.substring(1)]);
+		} else if (val[0] === '$') { // ref (const)
+			return getColor(theme.props[val]);
+		} else if (val[0] === ':') { // func
+			const parts = val.split('<');
+			const func = parts.shift().substring(1);
+			const arg = parseFloat(parts.shift());
+			const color = getColor(parts.join('<'));
+
+			switch (func) {
+				case 'darken': return color.darken(arg);
+				case 'lighten': return color.lighten(arg);
+				case 'alpha': return color.setAlpha(arg);
+				case 'hue': return color.spin(arg);
+				case 'saturate': return color.saturate(arg);
+			}
+		}
+
+		// other case
+		return tinycolor(val);
+	}
+
+	const props = {};
+
+	for (const [k, v] of Object.entries(theme.props)) {
+		if (k.startsWith('$')) continue; // ignore const
+
+		props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
+	}
+
+	return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+	return c.toRgbString();
+}
+
+export function validateTheme(theme: Record<string, any>): boolean {
+	if (theme.id == null || typeof theme.id !== 'string') return false;
+	if (theme.name == null || typeof theme.name !== 'string') return false;
+	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
+	if (theme.props == null || typeof theme.props !== 'object') return false;
+	return true;
+}
+
+export function parseThemeCode(code: string): Theme {
+	let theme;
+
+	try {
+		theme = JSON5.parse(code);
+	} catch (err) {
+		throw new Error('Failed to parse theme json');
+	}
+	if (!validateTheme(theme)) {
+		throw new Error('This theme is invaild');
+	}
+	if (getThemes().some(t => t.id === theme.id)) {
+		throw new Error('This theme is already installed');
+	}
+
+	return theme;
+}
+
+export function previewTheme(code: string): void {
+	const theme = parseThemeCode(code);
+	if (theme) applyTheme(theme, false);
+}
+
+export async function installTheme(code: string): Promise<void> {
+	const theme = parseThemeCode(code);
+	if (!theme) return;
+	await addTheme(theme);
+}
diff --git a/packages/frontend/src/utility/time.ts b/packages/frontend/src/utility/time.ts
new file mode 100644
index 0000000000..275b67ed00
--- /dev/null
+++ b/packages/frontend/src/utility/time.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const dateTimeIntervals = {
+	'day': 86400000,
+	'hour': 3600000,
+	'ms': 1,
+};
+
+export function dateUTC(time: number[]): Date {
+	const d =
+		time.length === 2 ? Date.UTC(time[0], time[1])
+		: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
+		: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
+		: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
+		: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
+		: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
+		: null;
+
+	if (!d) throw new Error('wrong number of arguments');
+
+	return new Date(d);
+}
+
+export function isTimeSame(a: Date, b: Date): boolean {
+	return a.getTime() === b.getTime();
+}
+
+export function isTimeBefore(a: Date, b: Date): boolean {
+	return (a.getTime() - b.getTime()) < 0;
+}
+
+export function isTimeAfter(a: Date, b: Date): boolean {
+	return (a.getTime() - b.getTime()) > 0;
+}
+
+export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+	return new Date(x.getTime() + (value * dateTimeIntervals[span]));
+}
+
+export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+	return new Date(x.getTime() - (value * dateTimeIntervals[span]));
+}
diff --git a/packages/frontend/src/utility/timezones.ts b/packages/frontend/src/utility/timezones.ts
new file mode 100644
index 0000000000..c7582e06da
--- /dev/null
+++ b/packages/frontend/src/utility/timezones.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const timezones = [{
+	name: 'UTC',
+	abbrev: 'UTC',
+	offset: 0,
+}, {
+	name: 'Europe/Berlin',
+	abbrev: 'CET',
+	offset: 60,
+}, {
+	name: 'Asia/Tokyo',
+	abbrev: 'JST',
+	offset: 540,
+}, {
+	name: 'Asia/Seoul',
+	abbrev: 'KST',
+	offset: 540,
+}, {
+	name: 'Asia/Shanghai',
+	abbrev: 'CST',
+	offset: 480,
+}, {
+	name: 'Australia/Sydney',
+	abbrev: 'AEST',
+	offset: 600,
+}, {
+	name: 'Australia/Darwin',
+	abbrev: 'ACST',
+	offset: 570,
+}, {
+	name: 'Australia/Perth',
+	abbrev: 'AWST',
+	offset: 480,
+}, {
+	name: 'America/New_York',
+	abbrev: 'EST',
+	offset: -300,
+}, {
+	name: 'America/Mexico_City',
+	abbrev: 'CST',
+	offset: -360,
+}, {
+	name: 'America/Phoenix',
+	abbrev: 'MST',
+	offset: -420,
+}, {
+	name: 'America/Los_Angeles',
+	abbrev: 'PST',
+	offset: -480,
+}];
diff --git a/packages/frontend/src/utility/touch.ts b/packages/frontend/src/utility/touch.ts
new file mode 100644
index 0000000000..adc2e4c093
--- /dev/null
+++ b/packages/frontend/src/utility/touch.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import { deviceKind } from '@/utility/device-kind.js';
+
+const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
+
+export let isTouchUsing = deviceKind === 'tablet' || deviceKind === 'smartphone';
+
+if (isTouchSupported && !isTouchUsing) {
+	window.addEventListener('touchstart', () => {
+		// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
+		// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
+		isTouchUsing = true;
+	}, { passive: true });
+}
+
+/** (MkHorizontalSwipe) 横スワイプ中か？ */
+export const isHorizontalSwipeSwiping = ref(false);
diff --git a/packages/frontend/src/utility/unison-reload.ts b/packages/frontend/src/utility/unison-reload.ts
new file mode 100644
index 0000000000..a24941d02e
--- /dev/null
+++ b/packages/frontend/src/utility/unison-reload.ts
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// SafariがBroadcastChannel未実装なのでライブラリを使う
+import { BroadcastChannel } from 'broadcast-channel';
+
+export const reloadChannel = new BroadcastChannel<string | null>('reload');
+
+// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
+export function unisonReload(path?: string) {
+	if (path !== undefined) {
+		reloadChannel.postMessage(path);
+		location.href = path;
+	} else {
+		reloadChannel.postMessage(null);
+		location.reload();
+	}
+}
diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts
new file mode 100644
index 0000000000..d105a318a7
--- /dev/null
+++ b/packages/frontend/src/utility/upload.ts
@@ -0,0 +1,162 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { reactive, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { v4 as uuid } from 'uuid';
+import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
+import { apiUrl } from '@@/js/config.js';
+import { getCompressionConfig } from './upload/compress-config.js';
+import { $i } from '@/account.js';
+import { alert } from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
+import { prefer } from '@/preferences.js';
+
+type Uploading = {
+	id: string;
+	name: string;
+	progressMax: number | undefined;
+	progressValue: number | undefined;
+	img: string;
+};
+export const uploads = ref<Uploading[]>([]);
+
+const mimeTypeMap = {
+	'image/webp': 'webp',
+	'image/jpeg': 'jpg',
+	'image/png': 'png',
+} as const;
+
+export function uploadFile(
+	file: File,
+	folder?: string | Misskey.entities.DriveFolder,
+	name?: string,
+	keepOriginal: boolean = prefer.s.keepOriginalUploading,
+): Promise<Misskey.entities.DriveFile> {
+	if ($i == null) throw new Error('Not logged in');
+
+	const _folder = typeof folder === 'string' ? folder : folder?.id;
+
+	if (file.size > instance.maxFileSize) {
+		alert({
+			type: 'error',
+			title: i18n.ts.failedToUpload,
+			text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
+		});
+		return Promise.reject();
+	}
+
+	return new Promise((resolve, reject) => {
+		const id = uuid();
+
+		const reader = new FileReader();
+		reader.onload = async (): Promise<void> => {
+			const filename = name ?? file.name ?? 'untitled';
+			const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
+
+			const ctx = reactive<Uploading>({
+				id,
+				name: prefer.s.keepOriginalFilename ? filename : id + extension,
+				progressMax: undefined,
+				progressValue: undefined,
+				img: window.URL.createObjectURL(file),
+			});
+
+			uploads.value.push(ctx);
+
+			const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
+			let resizedImage: Blob | undefined;
+			if (config) {
+				try {
+					const resized = await readAndCompressImage(file, config);
+					if (resized.size < file.size || file.type === 'image/webp') {
+						// The compression may not always reduce the file size
+						// (and WebP is not browser safe yet)
+						resizedImage = resized;
+					}
+					if (_DEV_) {
+						const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
+						console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
+					}
+
+					ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
+				} catch (err) {
+					console.error('Failed to resize image', err);
+				}
+			}
+
+			const formData = new FormData();
+			formData.append('i', $i!.token);
+			formData.append('force', 'true');
+			formData.append('file', resizedImage ?? file);
+			formData.append('name', ctx.name);
+			if (_folder) formData.append('folderId', _folder);
+
+			const xhr = new XMLHttpRequest();
+			xhr.open('POST', apiUrl + '/drive/files/create', true);
+			xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
+				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
+					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
+					uploads.value = uploads.value.filter(x => x.id !== id);
+
+					if (xhr.status === 413) {
+						alert({
+							type: 'error',
+							title: i18n.ts.failedToUpload,
+							text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
+						});
+					} else if (ev.target?.response) {
+						const res = JSON.parse(ev.target.response);
+						if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: i18n.ts.cannotUploadBecauseInappropriate,
+							});
+						} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: i18n.ts.cannotUploadBecauseNoFreeSpace,
+							});
+						} else {
+							alert({
+								type: 'error',
+								title: i18n.ts.failedToUpload,
+								text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
+							});
+						}
+					} else {
+						alert({
+							type: 'error',
+							title: 'Failed to upload',
+							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
+						});
+					}
+
+					reject();
+					return;
+				}
+
+				const driveFile = JSON.parse(ev.target.response);
+
+				resolve(driveFile);
+
+				uploads.value = uploads.value.filter(x => x.id !== id);
+			}) as (ev: ProgressEvent<EventTarget>) => any;
+
+			xhr.upload.onprogress = ev => {
+				if (ev.lengthComputable) {
+					ctx.progressMax = ev.total;
+					ctx.progressValue = ev.loaded;
+				}
+			};
+
+			xhr.send(formData);
+		};
+		reader.readAsArrayBuffer(file);
+	});
+}
diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts
new file mode 100644
index 0000000000..3046b7f518
--- /dev/null
+++ b/packages/frontend/src/utility/upload/compress-config.ts
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import isAnimated from 'is-file-animated';
+import { isWebpSupported } from './isWebpSupported.js';
+import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
+
+const compressTypeMap = {
+	'image/jpeg': { quality: 0.90, mimeType: 'image/webp' },
+	'image/png': { quality: 1, mimeType: 'image/webp' },
+	'image/webp': { quality: 0.90, mimeType: 'image/webp' },
+	'image/svg+xml': { quality: 1, mimeType: 'image/webp' },
+} as const;
+
+const compressTypeMapFallback = {
+	'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/png': { quality: 1, mimeType: 'image/png' },
+	'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/svg+xml': { quality: 1, mimeType: 'image/png' },
+} as const;
+
+export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
+	const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
+	if (!imgConfig || await isAnimated(file)) {
+		return;
+	}
+
+	return {
+		maxWidth: 2048,
+		maxHeight: 2048,
+		debug: true,
+		...imgConfig,
+	};
+}
diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts
new file mode 100644
index 0000000000..2511236ecc
--- /dev/null
+++ b/packages/frontend/src/utility/upload/isWebpSupported.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+let isWebpSupportedCache: boolean | undefined;
+export function isWebpSupported() {
+	if (isWebpSupportedCache === undefined) {
+		const canvas = document.createElement('canvas');
+		canvas.width = 1;
+		canvas.height = 1;
+		isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp');
+	}
+	return isWebpSupportedCache;
+}
diff --git a/packages/frontend/src/utility/use-chart-tooltip.ts b/packages/frontend/src/utility/use-chart-tooltip.ts
new file mode 100644
index 0000000000..bba64fc6ee
--- /dev/null
+++ b/packages/frontend/src/utility/use-chart-tooltip.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted, onDeactivated, ref } from 'vue';
+import * as os from '@/os.js';
+import MkChartTooltip from '@/components/MkChartTooltip.vue';
+
+export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
+	const tooltipShowing = ref(false);
+	const tooltipX = ref(0);
+	const tooltipY = ref(0);
+	const tooltipTitle = ref<string | null>(null);
+	const tooltipSeries = ref<{
+		backgroundColor: string;
+		borderColor: string;
+		text: string;
+	}[] | null>(null);
+	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
+		showing: tooltipShowing,
+		x: tooltipX,
+		y: tooltipY,
+		title: tooltipTitle,
+		series: tooltipSeries,
+	}, {});
+
+	onUnmounted(() => {
+		disposeTooltipComponent();
+	});
+
+	onDeactivated(() => {
+		tooltipShowing.value = false;
+	});
+
+	function handler(context) {
+		if (context.tooltip.opacity === 0) {
+			tooltipShowing.value = false;
+			return;
+		}
+
+		tooltipTitle.value = context.tooltip.title[0];
+		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
+			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
+			borderColor: context.tooltip.labelColors[i].borderColor,
+			text: b.lines[0],
+		}));
+
+		const rect = context.chart.canvas.getBoundingClientRect();
+
+		tooltipShowing.value = true;
+		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
+		if (opts.position === 'top') {
+			tooltipY.value = rect.top + window.scrollY;
+		} else if (opts.position === 'middle') {
+			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
+		}
+	}
+
+	return {
+		handler,
+	};
+}
diff --git a/packages/frontend/src/utility/use-form.ts b/packages/frontend/src/utility/use-form.ts
new file mode 100644
index 0000000000..26cca839c3
--- /dev/null
+++ b/packages/frontend/src/utility/use-form.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, reactive, watch } from 'vue';
+import type { Reactive } from 'vue';
+
+function copy<T>(v: T): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+function unwrapReactive<T>(v: Reactive<T>): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
+	const currentState = reactive<T>(copy(initialState));
+	const previousState = reactive<T>(copy(initialState));
+
+	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
+	for (const key in currentState) {
+		modifiedStates[key] = false;
+	}
+	const modified = computed(() => Object.values(modifiedStates).some(v => v));
+	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
+
+	watch([currentState, previousState], () => {
+		for (const key in modifiedStates) {
+			modifiedStates[key] = currentState[key] !== previousState[key];
+		}
+	}, { deep: true });
+
+	async function _save() {
+		await save(unwrapReactive(currentState));
+		for (const key in currentState) {
+			previousState[key] = copy(currentState[key]);
+		}
+	}
+
+	function discard() {
+		for (const key in currentState) {
+			currentState[key] = copy(previousState[key]);
+		}
+	}
+
+	return {
+		state: currentState,
+		savedState: previousState,
+		modifiedStates,
+		modified,
+		modifiedCount,
+		save: _save,
+		discard,
+	};
+}
diff --git a/packages/frontend/src/utility/use-leave-guard.ts b/packages/frontend/src/utility/use-leave-guard.ts
new file mode 100644
index 0000000000..395c12a756
--- /dev/null
+++ b/packages/frontend/src/utility/use-leave-guard.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Ref } from 'vue';
+
+export function useLeaveGuard(enabled: Ref<boolean>) {
+	/* TODO
+	const setLeaveGuard = inject('setLeaveGuard');
+
+	if (setLeaveGuard) {
+		setLeaveGuard(async () => {
+			if (!enabled.value) return false;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return canceled;
+		});
+	} else {
+		onBeforeRouteLeave(async (to, from) => {
+			if (!enabled.value) return true;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return !canceled;
+		});
+	}
+	*/
+
+	/*
+	function onBeforeLeave(ev: BeforeUnloadEvent) {
+		if (enabled.value) {
+			ev.preventDefault();
+			ev.returnValue = '';
+		}
+	}
+
+	window.addEventListener('beforeunload', onBeforeLeave);
+	onUnmounted(() => {
+		window.removeEventListener('beforeunload', onBeforeLeave);
+	});
+	*/
+}
diff --git a/packages/frontend/src/utility/use-note-capture.ts b/packages/frontend/src/utility/use-note-capture.ts
new file mode 100644
index 0000000000..0bc10e90e4
--- /dev/null
+++ b/packages/frontend/src/utility/use-note-capture.ts
@@ -0,0 +1,124 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted } from 'vue';
+import type { Ref, ShallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useStream } from '@/stream.js';
+import { $i } from '@/account.js';
+
+export function useNoteCapture(props: {
+	rootEl: ShallowRef<HTMLElement | undefined>;
+	note: Ref<Misskey.entities.Note>;
+	pureNote: Ref<Misskey.entities.Note>;
+	isDeletedRef: Ref<boolean>;
+}) {
+	const note = props.note;
+	const pureNote = props.pureNote;
+	const connection = $i ? useStream() : null;
+
+	function onStreamNoteUpdated(noteData): void {
+		const { type, id, body } = noteData;
+
+		if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
+
+		switch (type) {
+			case 'reacted': {
+				const reaction = body.reaction;
+
+				if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
+					note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
+				}
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = currentCount + 1;
+				note.value.reactionCount += 1;
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = reaction;
+				}
+				break;
+			}
+
+			case 'unreacted': {
+				const reaction = body.reaction;
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
+				note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
+				if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = null;
+				}
+				break;
+			}
+
+			case 'pollVoted': {
+				const choice = body.choice;
+
+				const choices = [...note.value.poll.choices];
+				choices[choice] = {
+					...choices[choice],
+					votes: choices[choice].votes + 1,
+					...($i && (body.userId === $i.id) ? {
+						isVoted: true,
+					} : {}),
+				};
+
+				note.value.poll.choices = choices;
+				break;
+			}
+
+			case 'deleted': {
+				props.isDeletedRef.value = true;
+				break;
+			}
+		}
+	}
+
+	function capture(withHandler = false): void {
+		if (connection) {
+			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+			connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
+			if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
+			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function decapture(withHandler = false): void {
+		if (connection) {
+			connection.send('un', {
+				id: note.value.id,
+			});
+			if (pureNote.value.id !== note.value.id) {
+				connection.send('un', {
+					id: pureNote.value.id,
+				});
+			}
+			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function onStreamConnected() {
+		capture(false);
+	}
+
+	capture(true);
+	if (connection) {
+		connection.on('_connected_', onStreamConnected);
+	}
+
+	onUnmounted(() => {
+		decapture(true);
+		if (connection) {
+			connection.off('_connected_', onStreamConnected);
+		}
+	});
+}
diff --git a/packages/frontend/src/utility/use-tooltip.ts b/packages/frontend/src/utility/use-tooltip.ts
new file mode 100644
index 0000000000..d9ddfc8b5d
--- /dev/null
+++ b/packages/frontend/src/utility/use-tooltip.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, watch, onUnmounted } from 'vue';
+import type { Ref } from 'vue';
+
+export function useTooltip(
+	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
+	onShow: (showing: Ref<boolean>) => void,
+	delay = 300,
+): void {
+	let isHovering = false;
+
+	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
+	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
+	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
+	let shouldIgnoreMouseover = false;
+
+	let timeoutId: number;
+
+	let changeShowingState: (() => void) | null;
+
+	let autoHidingTimer;
+
+	const open = () => {
+		close();
+		if (!isHovering) return;
+		if (elRef.value == null) return;
+		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
+
+		const showing = ref(true);
+		onShow(showing);
+		changeShowingState = () => {
+			showing.value = false;
+		};
+
+		autoHidingTimer = window.setInterval(() => {
+			if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
+				if (!isHovering) return;
+				isHovering = false;
+				window.clearTimeout(timeoutId);
+				close();
+				window.clearInterval(autoHidingTimer);
+			}
+		}, 1000);
+	};
+
+	const close = () => {
+		if (changeShowingState != null) {
+			changeShowingState();
+			changeShowingState = null;
+		}
+	};
+
+	const onMouseover = () => {
+		if (isHovering) return;
+		if (shouldIgnoreMouseover) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onMouseleave = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const onTouchstart = () => {
+		shouldIgnoreMouseover = true;
+		if (isHovering) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onTouchend = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const stop = watch(elRef, () => {
+		if (elRef.value) {
+			stop();
+			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+			el.addEventListener('mouseover', onMouseover, { passive: true });
+			el.addEventListener('mouseleave', onMouseleave, { passive: true });
+			el.addEventListener('touchstart', onTouchstart, { passive: true });
+			el.addEventListener('touchend', onTouchend, { passive: true });
+			el.addEventListener('click', close, { passive: true });
+		}
+	}, {
+		immediate: true,
+		flush: 'post',
+	});
+
+	onUnmounted(() => {
+		close();
+	});
+}
diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue
index cf1110da2b..d911e71ab2 100644
--- a/packages/frontend/src/widgets/WidgetActivity.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.vue
@@ -25,8 +25,8 @@ import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
 import XCalendar from './WidgetActivity.calendar.vue';
 import XChart from './WidgetActivity.chart.vue';
-import type { GetFormResultType } from '@/scripts/form.js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import type { GetFormResultType } from '@/utility/form.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue
index 9b04c463ba..29e21ee6c3 100644
--- a/packages/frontend/src/widgets/WidgetAichan.vue
+++ b/packages/frontend/src/widgets/WidgetAichan.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, onUnmounted, shallowRef } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 
 const name = 'ai';
 
diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue
index 80573d2dc4..b49041158f 100644
--- a/packages/frontend/src/widgets/WidgetAiscript.vue
+++ b/packages/frontend/src/widgets/WidgetAiscript.vue
@@ -23,10 +23,10 @@ import { ref } from 'vue';
 import { Interpreter, Parser, utils } from '@syuilo/aiscript';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
index f0f81a4a89..fb9dea1847 100644
--- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue
+++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
@@ -18,14 +18,14 @@ import type { Ref } from 'vue';
 import { Interpreter, Parser } from '@syuilo/aiscript';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import { $i } from '@/account.js';
 import MkAsUi from '@/components/MkAsUi.vue';
 import MkContainer from '@/components/MkContainer.vue';
-import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
-import type { AsUiComponent, AsUiRoot } from '@/scripts/aiscript/ui.js';
+import { registerAsUiLib } from '@/aiscript/ui.js';
+import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
 
 const name = 'aiscriptApp';
 
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 98e186f836..8c7507ef44 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -28,9 +28,9 @@ import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/account.js';
diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue
index 3e455bee3b..3f0f9eb9fd 100644
--- a/packages/frontend/src/widgets/WidgetButton.vue
+++ b/packages/frontend/src/widgets/WidgetButton.vue
@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { Interpreter, Parser } from '@syuilo/aiscript';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import { $i } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
 
diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue
index 94169d5e40..54f78469b2 100644
--- a/packages/frontend/src/widgets/WidgetCalendar.vue
+++ b/packages/frontend/src/widgets/WidgetCalendar.vue
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import { i18n } from '@/i18n.js';
 import { useInterval } from '@@/js/use-interval.js';
 
diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue
index 2b3663d35b..87ffd3d732 100644
--- a/packages/frontend/src/widgets/WidgetClicker.vue
+++ b/packages/frontend/src/widgets/WidgetClicker.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkClickerGame from '@/components/MkClickerGame.vue';
 
diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue
index 2d3f57a92f..826ecf6e02 100644
--- a/packages/frontend/src/widgets/WidgetClock.vue
+++ b/packages/frontend/src/widgets/WidgetClock.vue
@@ -32,11 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkAnalogClock from '@/components/MkAnalogClock.vue';
 import MkDigitalClock from '@/components/MkDigitalClock.vue';
-import { timezones } from '@/scripts/timezones.js';
+import { timezones } from '@/utility/timezones.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'clock';
diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue
index 6e0c9e6dfc..d79ec79d4f 100644
--- a/packages/frontend/src/widgets/WidgetDigitalClock.vue
+++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue
@@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
-import { timezones } from '@/scripts/timezones.js';
+import type { GetFormResultType } from '@/utility/form.js';
+import { timezones } from '@/utility/timezones.js';
 import MkDigitalClock from '@/components/MkDigitalClock.vue';
 
 const name = 'digitalClock';
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue
index 6e56c9115e..ca6f27bd09 100644
--- a/packages/frontend/src/widgets/WidgetFederation.vue
+++ b/packages/frontend/src/widgets/WidgetFederation.vue
@@ -30,12 +30,12 @@ import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkMiniChart from '@/components/MkMiniChart.vue';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
 
 const name = 'federation';
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index aee9066731..c6724127fa 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -22,13 +22,13 @@ import { shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkTagCloud from '@/components/MkTagCloud.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const name = 'instanceCloud';
 
diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue
index 69832332b1..8d721298d5 100644
--- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import { host } from '@@/js/config.js';
 import { instance } from '@/instance.js';
 
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index 00d0ec7fa5..485e532d51 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -54,11 +54,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onUnmounted, reactive, ref } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import { useStream } from '@/stream.js';
 import kmg from '@/filters/kmg.js';
-import * as sound from '@/scripts/sound.js';
-import { deepClone } from '@/scripts/clone.js';
+import * as sound from '@/utility/sound.js';
+import { deepClone } from '@/utility/clone.js';
 import { prefer } from '@/preferences.js';
 
 const name = 'jobQueue';
diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue
index 4705de2016..256cbc8021 100644
--- a/packages/frontend/src/widgets/WidgetMemo.vue
+++ b/packages/frontend/src/widgets/WidgetMemo.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, watch } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue
index 8aaed2624d..c5e1324ef5 100644
--- a/packages/frontend/src/widgets/WidgetNotifications.vue
+++ b/packages/frontend/src/widgets/WidgetNotifications.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { defineAsyncComponent } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import XNotifications from '@/components/MkNotifications.vue';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
index 4a3cdb9ba3..f6bd4c0025 100644
--- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue
+++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
@@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
-import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
+import type { GetFormResultType } from '@/utility/form.js';
+import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import number from '@/filters/number.js';
diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue
index 6c7c82f229..5d6e2ed48f 100644
--- a/packages/frontend/src/widgets/WidgetPhotos.vue
+++ b/packages/frontend/src/widgets/WidgetPhotos.vue
@@ -26,10 +26,10 @@ import { onUnmounted, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import { useStream } from '@/stream.js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { getStaticImageUrl } from '@/utility/media-proxy.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue
index b0a62d1be2..3170eab305 100644
--- a/packages/frontend/src/widgets/WidgetPostForm.vue
+++ b/packages/frontend/src/widgets/WidgetPostForm.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkPostForm from '@/components/MkPostForm.vue';
 
 const name = 'postForm';
diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue
index 9f006945ab..c86d1c9653 100644
--- a/packages/frontend/src/widgets/WidgetProfile.vue
+++ b/packages/frontend/src/widgets/WidgetProfile.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import { $i } from '@/account.js';
 import { userPage } from '@/filters/user.js';
 
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index 0d7ce55be3..82dfeacff1 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -27,7 +27,7 @@ import { ref, watch, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { url as base } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index 5ecc1ab022..13b76533d7 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -32,9 +32,9 @@ import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import MarqueeText from '@/components/MkMarquee.vue';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { shuffle } from '@/scripts/shuffle.js';
+import { shuffle } from '@/utility/shuffle.js';
 import { url as base } from '@@/js/config.js';
 import { useInterval } from '@@/js/use-interval.js';
 
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 67d35d71db..2cbf3a8ef6 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -21,9 +21,9 @@ import { onMounted, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue
index 02db9454a8..47dec05303 100644
--- a/packages/frontend/src/widgets/WidgetTimeline.vue
+++ b/packages/frontend/src/widgets/WidgetTimeline.vue
@@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkTimeline from '@/components/MkTimeline.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue
index 1e3ca74d40..db09031c33 100644
--- a/packages/frontend/src/widgets/WidgetTrends.vue
+++ b/packages/frontend/src/widgets/WidgetTrends.vue
@@ -29,10 +29,10 @@ import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import MkMiniChart from '@/components/MkMiniChart.vue';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue
index f85d27d6aa..f51ef12a2a 100644
--- a/packages/frontend/src/widgets/WidgetUnixClock.vue
+++ b/packages/frontend/src/widgets/WidgetUnixClock.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onUnmounted, ref, watch } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 
 const name = 'unixClock';
 
diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue
index 805e14c669..eb86732817 100644
--- a/packages/frontend/src/widgets/WidgetUserList.vue
+++ b/packages/frontend/src/widgets/WidgetUserList.vue
@@ -28,10 +28,10 @@ import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
-import type { GetFormResultType } from '@/scripts/form.js';
+import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
 import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue
index 3264c8dc04..9026fefb20 100644
--- a/packages/frontend/src/widgets/server-metric/index.vue
+++ b/packages/frontend/src/widgets/server-metric/index.vue
@@ -30,8 +30,8 @@ import XCpu from './cpu.vue';
 import XMemory from './mem.vue';
 import XDisk from './disk.vue';
 import MkContainer from '@/components/MkContainer.vue';
-import type { GetFormResultType } from '@/scripts/form.js';
-import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import type { GetFormResultType } from '@/utility/form.js';
+import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts
index 98e1e44cd8..de4c369cbb 100644
--- a/packages/frontend/src/widgets/widget.ts
+++ b/packages/frontend/src/widgets/widget.ts
@@ -5,9 +5,9 @@
 
 import { reactive, watch } from 'vue';
 import { throttle } from 'throttle-debounce';
-import type { Form, GetFormResultType } from '@/scripts/form.js';
+import type { Form, GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
-import { deepClone } from '@/scripts/clone.js';
+import { deepClone } from '@/utility/clone.js';
 
 export type Widget<P extends Record<string, unknown>> = {
 	id: string;
diff --git a/packages/frontend/test/aiscript/api.test.ts b/packages/frontend/test/aiscript/api.test.ts
index 2a15a74249..a569c0fa51 100644
--- a/packages/frontend/test/aiscript/api.test.ts
+++ b/packages/frontend/test/aiscript/api.test.ts
@@ -4,7 +4,7 @@
  */
 
 import { miLocalStorage } from '@/local-storage.js';
-import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import { errors, Interpreter, Parser, values } from '@syuilo/aiscript';
 import {
 	afterAll,
@@ -59,7 +59,7 @@ vi.mock('@/os.js', () => {
 
 const misskeyApiMock = vi.hoisted(() => vi.fn());
 
-vi.mock('@/scripts/misskey-api.js', () => {
+vi.mock('@/utility/misskey-api.js', () => {
 	return { misskeyApi: misskeyApiMock };
 });
 
diff --git a/packages/frontend/test/aiscript/common.test.ts b/packages/frontend/test/aiscript/common.test.ts
index acc48826ea..c0c978001b 100644
--- a/packages/frontend/test/aiscript/common.test.ts
+++ b/packages/frontend/test/aiscript/common.test.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { assertStringAndIsIn } from "@/scripts/aiscript/common.js";
+import { assertStringAndIsIn } from "@/aiscript/common.js";
 import { values } from "@syuilo/aiscript";
 import { describe, expect, test } from "vitest";
 
diff --git a/packages/frontend/test/aiscript/ui.test.ts b/packages/frontend/test/aiscript/ui.test.ts
index 5f77edbb49..44a50aaa62 100644
--- a/packages/frontend/test/aiscript/ui.test.ts
+++ b/packages/frontend/test/aiscript/ui.test.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
+import { registerAsUiLib } from '@/aiscript/ui.js';
 import { errors, Interpreter, Parser, values } from '@syuilo/aiscript';
 import { describe, expect, test } from 'vitest';
 import { type Ref, ref } from 'vue';
@@ -19,7 +19,7 @@ import type {
 	AsUiText,
 	AsUiTextarea,
 	AsUiTextInput,
-} from '@/scripts/aiscript/ui.js';
+} from '@/aiscript/ui.js';
 
 type ExeResult = {
 	root: AsUiRoot;
diff --git a/packages/frontend/test/autocomplete.test.ts b/packages/frontend/test/autocomplete.test.ts
index 394ac3a821..38be35813f 100644
--- a/packages/frontend/test/autocomplete.test.ts
+++ b/packages/frontend/test/autocomplete.test.ts
@@ -4,7 +4,7 @@
  */
 
 import { assert, describe, test } from 'vitest';
-import { searchEmoji } from '@/scripts/search-emoji.js';
+import { searchEmoji } from '@/utility/search-emoji.js';
 
 describe('emoji autocomplete', () => {
 	test('名前の完全一致は名前の前方一致より優先される', async () => {
diff --git a/packages/frontend/test/intl-string.test.ts b/packages/frontend/test/intl-string.test.ts
index dd545b45d1..b52824db86 100644
--- a/packages/frontend/test/intl-string.test.ts
+++ b/packages/frontend/test/intl-string.test.ts
@@ -10,7 +10,7 @@ import {
 	normalizeStringWithHiragana,
 	compareStringEquals,
 	compareStringIncludes,
-} from '@/scripts/intl-string.js';
+} from '@/utility/intl-string.js';
 
 // 共通のテストを実行するヘルパー関数
 const runCommonTests = (normalizeFn: (str: string) => string) => {
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 1c094e272b..a28fc553f4 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -86,7 +86,7 @@ export function getConfig(): UserConfig {
 		plugins: [
 			pluginCreateSearchIndex({
 				targetFilePaths: ['src/pages/settings/*.vue'],
-				exportFilePath: './src/scripts/autogen/settings-search-index.ts',
+				exportFilePath: './src/utility/autogen/settings-search-index.ts',
 				verbose: process.env.FRONTEND_SEARCH_INDEX_VERBOSE === 'true',
 			}),
 			pluginVue(),
-- 
cgit v1.2.3-freya


From 27e8805dcbddb73571c8d7fea79c852d9697a26b Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 9 Mar 2025 17:02:46 +0900
Subject: refactor(frontend): relocate plugin consts

---
 packages/frontend/src/components/MkNote.vue        |  2 +-
 .../frontend/src/components/MkNoteDetailed.vue     |  2 +-
 packages/frontend/src/components/MkPostForm.vue    |  9 +++---
 packages/frontend/src/pages/page.vue               |  2 +-
 packages/frontend/src/plugin.ts                    | 37 +++++++++++++++++++++-
 packages/frontend/src/store.ts                     | 34 --------------------
 packages/frontend/src/utility/get-note-menu.ts     |  5 +--
 packages/frontend/src/utility/get-user-menu.ts     |  2 +-
 8 files changed, 48 insertions(+), 45 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 0daa917500..f3f8c1ce82 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -206,7 +206,6 @@ import number from '@/filters/number.js';
 import * as os from '@/os.js';
 import * as sound from '@/utility/sound.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
-import { noteViewInterruptors } from '@/store.js';
 import { reactionPicker } from '@/utility/reaction-picker.js';
 import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
@@ -223,6 +222,7 @@ import { isEnabledUrlPreview } from '@/instance.js';
 import { focusPrev, focusNext } from '@/utility/focus.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
+import { noteViewInterruptors } from '@/plugin.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 10f375fcae..7f982858f1 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -236,7 +236,6 @@ import number from '@/filters/number.js';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import * as sound from '@/utility/sound.js';
-import { noteViewInterruptors } from '@/store.js';
 import { reactionPicker } from '@/utility/reaction-picker.js';
 import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
@@ -255,6 +254,7 @@ import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
+import { noteViewInterruptors } from '@/plugin.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 306c4be61b..4edb147b86 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -104,18 +104,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue';
-import type { ShallowRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import insertTextAtCursor from 'insert-text-at-cursor';
 import { toASCII } from 'punycode.js';
 import { host, url } from '@@/js/config.js';
+import type { ShallowRef } from 'vue';
 import type { PostFormProps } from '@/types/post-form.js';
-import MkNoteSimple from '@/components/MkNoteSimple.vue';
+import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
 import MkNotePreview from '@/components/MkNotePreview.vue';
 import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
 import MkPollEditor from '@/components/MkPollEditor.vue';
-import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
+import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import { erase, unique } from '@/utility/array.js';
 import { extractMentions } from '@/utility/extract-mentions.js';
 import { formatTimeString } from '@/utility/format-time-string.js';
@@ -123,7 +123,7 @@ import { Autocomplete } from '@/utility/autocomplete.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { selectFiles } from '@/utility/select-file.js';
-import { store, notePostInterruptors, postFormActions } from '@/store.js';
+import { store } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
@@ -136,6 +136,7 @@ import { claimAchievement } from '@/utility/achievements.js';
 import { emojiPicker } from '@/utility/emoji-picker.js';
 import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
 import { prefer } from '@/preferences.js';
+import { notePostInterruptors, postFormActions } from '@/plugin.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 420b83035b..18322cde91 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -114,7 +114,6 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkPagePreview from '@/components/MkPagePreview.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/utility/page-metadata.js';
-import { pageViewInterruptors } from '@/store.js';
 import { deepClone } from '@/utility/clone.js';
 import { $i } from '@/account.js';
 import { isSupportShare } from '@/utility/navigator.js';
@@ -123,6 +122,7 @@ import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
 import { prefer } from '@/preferences.js';
+import { pageViewInterruptors } from '@/plugin.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index 4d56be75b9..3639d628c2 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -7,8 +7,9 @@ import { ref, defineAsyncComponent } from 'vue';
 import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
 import { compareVersions } from 'compare-versions';
 import { v4 as uuid } from 'uuid';
+import * as Misskey from 'misskey-js';
 import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
-import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors, store } from '@/store.js';
+import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
@@ -36,6 +37,40 @@ export type AiScriptPluginMeta = {
 	config?: Record<string, any>;
 };
 
+interface PostFormAction {
+	title: string,
+	handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
+}
+
+interface UserAction {
+	title: string,
+	handler: (user: Misskey.entities.UserDetailed) => void;
+}
+
+interface NoteAction {
+	title: string,
+	handler: (note: Misskey.entities.Note) => void;
+}
+
+interface NoteViewInterruptor {
+	handler: (note: Misskey.entities.Note) => unknown;
+}
+
+interface NotePostInterruptor {
+	handler: (note: FIXME) => unknown;
+}
+
+interface PageViewInterruptor {
+	handler: (page: Misskey.entities.Page) => unknown;
+}
+
+export const postFormActions: PostFormAction[] = [];
+export const userActions: UserAction[] = [];
+export const noteActions: NoteAction[] = [];
+export const noteViewInterruptors: NoteViewInterruptor[] = [];
+export const notePostInterruptors: NotePostInterruptor[] = [];
+export const pageViewInterruptors: PageViewInterruptor[] = [];
+
 const parser = new Parser();
 
 export function isSupportedAiScriptVersion(version: string): boolean {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 32b4543f52..196ec782ff 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -15,40 +15,6 @@ import { miLocalStorage } from '@/local-storage.js';
 import { Storage } from '@/pizzax.js';
 import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
 
-interface PostFormAction {
-	title: string,
-	handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
-}
-
-interface UserAction {
-	title: string,
-	handler: (user: Misskey.entities.UserDetailed) => void;
-}
-
-interface NoteAction {
-	title: string,
-	handler: (note: Misskey.entities.Note) => void;
-}
-
-interface NoteViewInterruptor {
-	handler: (note: Misskey.entities.Note) => unknown;
-}
-
-interface NotePostInterruptor {
-	handler: (note: FIXME) => unknown;
-}
-
-interface PageViewInterruptor {
-	handler: (page: Misskey.entities.Page) => unknown;
-}
-
-export const postFormActions: PostFormAction[] = [];
-export const userActions: UserAction[] = [];
-export const noteActions: NoteAction[] = [];
-export const noteViewInterruptors: NoteViewInterruptor[] = [];
-export const notePostInterruptors: NotePostInterruptor[] = [];
-export const pageViewInterruptors: PageViewInterruptor[] = [];
-
 /**
  * 「状態」を管理するストア(not「設定」)
  */
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
index c95eaa20dd..7bc4ba1026 100644
--- a/packages/frontend/src/utility/get-note-menu.ts
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -4,10 +4,10 @@
  */
 
 import { defineAsyncComponent } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { url } from '@@/js/config.js';
 import { claimAchievement } from './achievements.js';
+import type { Ref, ShallowRef } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
@@ -15,7 +15,7 @@ import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
-import { store, noteActions } from '@/store.js';
+import { store } from '@/store.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { getUserMenu } from '@/utility/get-user-menu.js';
 import { clipsCache, favoritedChannelsCache } from '@/cache.js';
@@ -24,6 +24,7 @@ import { isSupportShare } from '@/utility/navigator.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { prefer } from '@/preferences.js';
+import { noteActions } from '@/plugin.js';
 
 export async function getNoteClipMenu(props: {
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index d739976cb1..5fc1ffd3eb 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -13,13 +13,13 @@ import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { userActions } from '@/store.js';
 import { $i, iAmModerator } from '@/account.js';
 import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { prefer } from '@/preferences.js';
+import { userActions } from '@/plugin.js';
 
 export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
 	const meId = $i ? $i.id : null;
-- 
cgit v1.2.3-freya


From 0402866b4378e014943cb03031953e07bd082a35 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 9 Mar 2025 21:23:36 +0900
Subject: enhance(frontend): improve plugin management

---
 packages/frontend/src/components/MkNote.vue        |   3 +-
 .../frontend/src/components/MkNoteDetailed.vue     |   3 +-
 packages/frontend/src/components/MkPostForm.vue    |   4 +-
 packages/frontend/src/pages/page.vue               |   3 +-
 packages/frontend/src/pages/settings/plugin.vue    |  33 +--
 packages/frontend/src/plugin.ts                    | 223 ++++++++++-----------
 packages/frontend/src/utility/get-note-menu.ts     |   3 +-
 packages/frontend/src/utility/get-user-menu.ts     |   3 +-
 8 files changed, 131 insertions(+), 144 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index f3f8c1ce82..ca778d87de 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -222,7 +222,7 @@ import { isEnabledUrlPreview } from '@/instance.js';
 import { focusPrev, focusNext } from '@/utility/focus.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
-import { noteViewInterruptors } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -248,6 +248,7 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul
 const note = ref(deepClone(props.note));
 
 // plugin
+const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
 if (noteViewInterruptors.length > 0) {
 	onMounted(async () => {
 		let result: Misskey.entities.Note | null = deepClone(note.value);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 7f982858f1..3f3dbf6d83 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -254,7 +254,7 @@ import MkButton from '@/components/MkButton.vue';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { prefer } from '@/preferences.js';
-import { noteViewInterruptors } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -268,6 +268,7 @@ const inChannel = inject('inChannel', null);
 const note = ref(deepClone(props.note));
 
 // plugin
+const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
 if (noteViewInterruptors.length > 0) {
 	onMounted(async () => {
 		let result: Misskey.entities.Note | null = deepClone(note.value);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 4edb147b86..755e13279c 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -136,7 +136,7 @@ import { claimAchievement } from '@/utility/achievements.js';
 import { emojiPicker } from '@/utility/emoji-picker.js';
 import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
 import { prefer } from '@/preferences.js';
-import { notePostInterruptors, postFormActions } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 const $i = signinRequired();
 
@@ -197,6 +197,7 @@ const showingOptions = ref(false);
 const textAreaReadOnly = ref(false);
 const justEndedComposition = ref(false);
 const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
+const postFormActions = getPluginHandlers('post_form_action');
 
 const draftKey = computed((): string => {
 	let key = props.channel ? `channel:${props.channel.id}` : '';
@@ -823,6 +824,7 @@ async function post(ev?: MouseEvent) {
 	}
 
 	// plugin
+	const notePostInterruptors = getPluginHandlers('note_post_interruptor');
 	if (notePostInterruptors.length > 0) {
 		for (const interruptor of notePostInterruptors) {
 			try {
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 18322cde91..0405a722ab 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -122,7 +122,7 @@ import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
 import { prefer } from '@/preferences.js';
-import { pageViewInterruptors } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 const router = useRouter();
 
@@ -151,6 +151,7 @@ function fetchPage() {
 		page.value = _page;
 
 		// plugin
+		const pageViewInterruptors = getPluginHandlers('page_view_interruptor');
 		if (pageViewInterruptors.length > 0) {
 			let result = deepClone(_page);
 			for (const interruptor of pageViewInterruptors) {
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index 741c776bb8..5619d696f2 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -28,14 +28,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</template>
 					<template #footer>
 						<div class="_buttons">
-							<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
-							<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
+							<MkButton :disabled="!plugin.active" @click="reload(plugin)"><i class="ti ti-refresh"></i> {{ i18n.ts.reload }}</MkButton>
+							<MkButton danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
+							<MkButton v-if="plugin.config" style="margin-left: auto;" @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
 						</div>
 					</template>
 
 					<div class="_gaps_m">
 						<div class="_gaps_s">
-							<span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
 							<MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
 						</div>
 
@@ -64,10 +64,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<template #label>{{ i18n.ts._plugin.viewLog }}</template>
 
 							<div class="_gaps_s">
-								<div class="_buttons">
-									<MkButton inline @click="copy(pluginLogs.get(plugin.installId)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
-								</div>
-
 								<MkCode :code="pluginLogs.get(plugin.installId)?.join('\n') ?? ''"/>
 							</div>
 						</MkFolder>
@@ -77,10 +73,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<template #label>{{ i18n.ts._plugin.viewSource }}</template>
 
 							<div class="_gaps_s">
-								<div class="_buttons">
-									<MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
-								</div>
-
 								<MkCode :code="plugin.src ?? ''" lang="ais"/>
 							</div>
 						</MkFolder>
@@ -94,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { nextTick, ref, computed } from 'vue';
+import type { Plugin } from '@/plugin.js';
 import FormLink from '@/components/form/link.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import FormSection from '@/components/form/section.vue';
@@ -101,36 +94,30 @@ import MkButton from '@/components/MkButton.vue';
 import MkCode from '@/components/MkCode.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import * as os from '@/os.js';
-import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { unisonReload } from '@/utility/unison-reload.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/utility/page-metadata.js';
-import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin } from '@/plugin.js';
+import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js';
 import { prefer } from '@/preferences.js';
 
 const plugins = prefer.r.plugins;
 
-async function uninstall(plugin) {
+async function uninstall(plugin: Plugin) {
 	await uninstallPlugin(plugin);
 	nextTick(() => {
 		unisonReload();
 	});
 }
 
-function copy(text) {
-	copyToClipboard(text ?? '');
-	os.success();
+function reload(plugin: Plugin) {
+	reloadPlugin(plugin);
 }
 
-async function config(plugin) {
+async function config(plugin: Plugin) {
 	await configPlugin(plugin);
-	nextTick(() => {
-		location.reload();
-	});
 }
 
-function changeActive(plugin, active) {
+function changeActive(plugin: Plugin, active: boolean) {
 	changePluginActive(plugin, active);
 	nextTick(() => {
 		location.reload();
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index 3639d628c2..15644870e5 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -37,40 +37,6 @@ export type AiScriptPluginMeta = {
 	config?: Record<string, any>;
 };
 
-interface PostFormAction {
-	title: string,
-	handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
-}
-
-interface UserAction {
-	title: string,
-	handler: (user: Misskey.entities.UserDetailed) => void;
-}
-
-interface NoteAction {
-	title: string,
-	handler: (note: Misskey.entities.Note) => void;
-}
-
-interface NoteViewInterruptor {
-	handler: (note: Misskey.entities.Note) => unknown;
-}
-
-interface NotePostInterruptor {
-	handler: (note: FIXME) => unknown;
-}
-
-interface PageViewInterruptor {
-	handler: (page: Misskey.entities.Page) => unknown;
-}
-
-export const postFormActions: PostFormAction[] = [];
-export const userActions: UserAction[] = [];
-export const noteActions: NoteAction[] = [];
-export const noteViewInterruptors: NoteViewInterruptor[] = [];
-export const notePostInterruptors: NotePostInterruptor[] = [];
-export const pageViewInterruptors: PageViewInterruptor[] = [];
-
 const parser = new Parser();
 
 export function isSupportedAiScriptVersion(version: string): boolean {
@@ -215,6 +181,42 @@ export function changePluginActive(plugin: Plugin, active: boolean) {
 const pluginContexts = new Map<string, Interpreter>();
 export const pluginLogs = ref(new Map<string, string[]>());
 
+type HandlerDef = {
+	post_form_action: {
+		title: string,
+		handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
+	};
+	user_action: {
+		title: string,
+		handler: (user: Misskey.entities.UserDetailed) => void;
+	};
+	note_action: {
+		title: string,
+		handler: (note: Misskey.entities.Note) => void;
+	};
+	note_view_interruptor: {
+		handler: (note: Misskey.entities.Note) => unknown;
+	};
+	note_post_interruptor: {
+		handler: (note: FIXME) => unknown;
+	};
+	page_view_interruptor: {
+		handler: (page: Misskey.entities.Page) => unknown;
+	};
+};
+
+type PluginHandler<K extends keyof HandlerDef> = {
+	pluginInstallId: string;
+	type: K;
+	ctx: HandlerDef[K];
+};
+
+let pluginHandlers: PluginHandler<keyof HandlerDef>[] = [];
+
+function addPluginHandler<K extends keyof HandlerDef>(installId: Plugin['installId'], type: K, ctx: PluginHandler<K>['ctx']) {
+	pluginHandlers.push({ pluginInstallId: installId, type, ctx });
+}
+
 export async function launchPlugin(plugin: Plugin): Promise<void> {
 	// 後方互換性のため
 	if (plugin.src == null) return;
@@ -252,6 +254,18 @@ export async function launchPlugin(plugin: Plugin): Promise<void> {
 	);
 }
 
+export function reloadPlugin(plugin: Plugin): void {
+	const pluginContext = pluginContexts.get(plugin.installId);
+	if (!pluginContext) return;
+
+	pluginContext.abort();
+	pluginContexts.delete(plugin.installId);
+	pluginLogs.value.delete(plugin.installId);
+	pluginHandlers = pluginHandlers.filter(x => x.pluginInstallId !== plugin.installId);
+
+	launchPlugin(plugin);
+}
+
 function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
 	const id = opts.plugin.installId;
 
@@ -260,111 +274,90 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
 		config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
 	}
 
+	function withContext<T>(fn: (ctx: Interpreter) => T): T {
+		console.log('withContext', id);
+		const ctx = pluginContexts.get(id);
+		if (!ctx) throw new Error('Plugin context not found');
+		return fn(ctx);
+	}
+
 	return {
 		...createAiScriptEnv({ ...opts, token: store.state.pluginTokens[id] }),
 
 		'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerPostFormAction({ pluginId: id, title: title.value, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'post_form_action', {
+				title: title.value,
+				handler: withContext(ctx => (form, update) => {
+					ctx.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
+						if (!key || !value) {
+							return;
+						}
+						update(utils.valToJs(key), utils.valToJs(value));
+					})]);
+				}),
+			});
 		}),
+
 		'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerUserAction({ pluginId: id, title: title.value, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'user_action', {
+				title: title.value,
+				handler: withContext(ctx => (user) => {
+					ctx.execFn(handler, [utils.jsToVal(user)]);
+				}),
+			});
 		}),
+
 		'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => {
 			utils.assertString(title);
-			registerNoteAction({ pluginId: id, title: title.value, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'note_action', {
+				title: title.value,
+				handler: withContext(ctx => (note) => {
+					ctx.execFn(handler, [utils.jsToVal(note)]);
+				}),
+			});
 		}),
+
 		'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNoteViewInterruptor({ pluginId: id, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'note_view_interruptor', {
+				handler: withContext(ctx => async (note) => {
+					return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)]));
+				}),
+			});
 		}),
+
 		'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => {
-			registerNotePostInterruptor({ pluginId: id, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'note_post_interruptor', {
+				handler: withContext(ctx => async (note) => {
+					return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)]));
+				}),
+			});
 		}),
+
 		'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => {
-			registerPageViewInterruptor({ pluginId: id, handler });
+			utils.assertFunction(handler);
+			addPluginHandler(id, 'page_view_interruptor', {
+				handler: withContext(ctx => async (page) => {
+					return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(page)]));
+				}),
+			});
 		}),
+
 		'Plugin:open_url': values.FN_NATIVE(([url]) => {
 			utils.assertString(url);
 			window.open(url.value, '_blank', 'noopener');
 		}),
+
 		'Plugin:config': values.OBJ(config),
 	};
 }
 
-function registerPostFormAction({ pluginId, title, handler }): void {
-	postFormActions.push({
-		title, handler: (form, update) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
-				if (!key || !value) {
-					return;
-				}
-				update(utils.valToJs(key), utils.valToJs(value));
-			})]);
-		},
-	});
-}
-
-function registerUserAction({ pluginId, title, handler }): void {
-	userActions.push({
-		title, handler: (user) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			pluginContext.execFn(handler, [utils.jsToVal(user)]);
-		},
-	});
-}
-
-function registerNoteAction({ pluginId, title, handler }): void {
-	noteActions.push({
-		title, handler: (note) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			pluginContext.execFn(handler, [utils.jsToVal(note)]);
-		},
-	});
-}
-
-function registerNoteViewInterruptor({ pluginId, handler }): void {
-	noteViewInterruptors.push({
-		handler: async (note) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)]));
-		},
-	});
-}
-
-function registerNotePostInterruptor({ pluginId, handler }): void {
-	notePostInterruptors.push({
-		handler: async (note) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)]));
-		},
-	});
-}
-
-function registerPageViewInterruptor({ pluginId, handler }): void {
-	pageViewInterruptors.push({
-		handler: async (page) => {
-			const pluginContext = pluginContexts.get(pluginId);
-			if (!pluginContext) {
-				return;
-			}
-			return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(page)]));
-		},
-	});
+export function getPluginHandlers<K extends keyof HandlerDef>(type: K): HandlerDef[K][] {
+	return pluginHandlers.filter((x): x is PluginHandler<K> => x.type === type).map(x => x.ctx);
 }
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
index 7bc4ba1026..9c37070784 100644
--- a/packages/frontend/src/utility/get-note-menu.ts
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -24,7 +24,7 @@ import { isSupportShare } from '@/utility/navigator.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { prefer } from '@/preferences.js';
-import { noteActions } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 export async function getNoteClipMenu(props: {
 	note: Misskey.entities.Note;
@@ -497,6 +497,7 @@ export function getNoteMenu(props: {
 		}
 	}
 
+	const noteActions = getPluginHandlers('note_action');
 	if (noteActions.length > 0) {
 		menuItems.push({ type: 'divider' });
 
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index 5fc1ffd3eb..1b9b0eac95 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -19,7 +19,7 @@ import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
 import { prefer } from '@/preferences.js';
-import { userActions } from '@/plugin.js';
+import { getPluginHandlers } from '@/plugin.js';
 
 export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
 	const meId = $i ? $i.id : null;
@@ -419,6 +419,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 		});
 	}
 
+	const userActions = getPluginHandlers('user_action');
 	if (userActions.length > 0) {
 		menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
 			icon: 'ti ti-plug',
-- 
cgit v1.2.3-freya


From 3ced310f77c2511366c421314a024885810a9082 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 13 Mar 2025 14:05:04 +0900
Subject: refactor(frontend): organize use functions

---
 packages/frontend/src/components/MkChart.vue       |   2 +-
 packages/frontend/src/components/MkHeatmap.vue     |   2 +-
 .../frontend/src/components/MkInstanceStats.vue    |   2 +-
 packages/frontend/src/components/MkLink.vue        |   2 +-
 packages/frontend/src/components/MkNote.vue        |   4 +-
 .../frontend/src/components/MkNoteDetailed.vue     |   4 +-
 .../frontend/src/components/MkReactionIcon.vue     |   2 +-
 .../src/components/MkReactionsViewer.reaction.vue  |   2 +-
 .../frontend/src/components/MkRetentionHeatmap.vue |   2 +-
 .../src/components/MkRetentionLineChart.vue        |   2 +-
 .../MkVisitorDashboard.ActiveUsersChart.vue        |   2 +-
 packages/frontend/src/components/global/MkUrl.vue  |   2 +-
 .../frontend/src/components/grid/MkDataCell.vue    |   2 +-
 .../frontend/src/pages/admin/bot-protection.vue    |   2 +-
 .../src/pages/admin/overview.active-users.vue      |   2 +-
 .../src/pages/admin/overview.ap-requests.vue       |   2 +-
 .../src/pages/admin/overview.federation.vue        |   2 +-
 packages/frontend/src/pages/admin/overview.pie.vue |   2 +-
 .../src/pages/admin/overview.queue.chart.vue       |   2 +-
 packages/frontend/src/pages/admin/performance.vue  |   2 +-
 .../frontend/src/pages/admin/queue.chart.chart.vue |   2 +-
 packages/frontend/src/pages/admin/security.vue     |   2 +-
 packages/frontend/src/pages/admin/settings.vue     |   2 +-
 packages/frontend/src/pages/theme-editor.vue       |   2 +-
 .../frontend/src/pages/user/activity.following.vue |   2 +-
 .../frontend/src/pages/user/activity.notes.vue     |   2 +-
 packages/frontend/src/pages/user/activity.pv.vue   |   2 +-
 packages/frontend/src/use/use-chart-tooltip.ts     |  63 +++++++++++
 packages/frontend/src/use/use-form.ts              |  56 ++++++++++
 packages/frontend/src/use/use-leave-guard.ts       |  50 +++++++++
 packages/frontend/src/use/use-note-capture.ts      | 124 +++++++++++++++++++++
 packages/frontend/src/use/use-tooltip.ts           | 106 ++++++++++++++++++
 packages/frontend/src/utility/use-chart-tooltip.ts |  63 -----------
 packages/frontend/src/utility/use-form.ts          |  56 ----------
 packages/frontend/src/utility/use-leave-guard.ts   |  50 ---------
 packages/frontend/src/utility/use-note-capture.ts  | 124 ---------------------
 packages/frontend/src/utility/use-tooltip.ts       | 106 ------------------
 37 files changed, 428 insertions(+), 428 deletions(-)
 create mode 100644 packages/frontend/src/use/use-chart-tooltip.ts
 create mode 100644 packages/frontend/src/use/use-form.ts
 create mode 100644 packages/frontend/src/use/use-leave-guard.ts
 create mode 100644 packages/frontend/src/use/use-note-capture.ts
 create mode 100644 packages/frontend/src/use/use-tooltip.ts
 delete mode 100644 packages/frontend/src/utility/use-chart-tooltip.ts
 delete mode 100644 packages/frontend/src/utility/use-form.ts
 delete mode 100644 packages/frontend/src/utility/use-leave-guard.ts
 delete mode 100644 packages/frontend/src/utility/use-note-capture.ts
 delete mode 100644 packages/frontend/src/utility/use-tooltip.ts

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index c1a55906ae..d9f4558014 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -55,7 +55,7 @@ import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import date from '@/filters/date.js';
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 8339e68b07..8a84bfd541 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 9d475bc8aa..70777bb89a 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -88,7 +88,7 @@ import { onMounted, ref, computed, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { $i } from '@/account.js';
 import * as os from '@/os.js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
index 140fc9816c..4bac2bcea4 100644
--- a/packages/frontend/src/components/MkLink.vue
+++ b/packages/frontend/src/components/MkLink.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent, ref } from 'vue';
 import { url as local } from '@@/js/config.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index ca778d87de..aa352d5163 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -211,9 +211,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { useNoteCapture } from '@/utility/use-note-capture.js';
+import { useNoteCapture } from '@/use/use-note-capture.js';
 import { deepClone } from '@/utility/clone.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import { getNoteSummary } from '@/utility/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 3f3dbf6d83..b3f99b702a 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -241,9 +241,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { useNoteCapture } from '@/utility/use-note-capture.js';
+import { useNoteCapture } from '@/use/use-note-capture.js';
 import { deepClone } from '@/utility/clone.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/utility/show-moved-dialog.js';
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index cb25580bc2..7551524246 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { defineAsyncComponent, shallowRef } from 'vue';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index f35c938d02..d079e68cde 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -26,7 +26,7 @@ import XDetails from '@/components/MkReactionsViewer.details.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import { $i } from '@/account.js';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
 import { claimAchievement } from '@/utility/achievements.js';
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index c53bf98f67..a962b37e4a 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -17,7 +17,7 @@ import { onMounted, nextTick, shallowRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 9e03be3e7f..6fa19efed5 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -12,7 +12,7 @@ import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index 872d4201bb..4168876f08 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -19,7 +19,7 @@ import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index d28db0d0ca..0c248b041d 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue';
 import { toUnicode as decodePunycode } from 'punycode.js';
 import { url as local } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
 
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index f813bcb73f..f7f6f5c140 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script setup lang="ts">
 import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
 import { GridEventEmitter } from '@/components/grid/grid.js';
-import { useTooltip } from '@/utility/use-tooltip.js';
+import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
 import type { Size } from '@/components/grid/grid.js';
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index 59b8435595..2bd734f7d3 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -163,7 +163,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { useForm } from '@/utility/use-form.js';
+import { useForm } from '@/use/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 7cada9b5be..5c50336b2c 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index bbfe60d205..1949201ca0 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -25,7 +25,7 @@ import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import isChromatic from 'chromatic';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { store } from '@/store.js';
 import { alpha } from '@/utility/color.js';
diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue
index 362f0974e0..6d6d431863 100644
--- a/packages/frontend/src/pages/admin/overview.federation.vue
+++ b/packages/frontend/src/pages/admin/overview.federation.vue
@@ -54,7 +54,7 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
 import number from '@/filters/number.js';
 import MkNumberDiff from '@/components/MkNumberDiff.vue';
 import { i18n } from '@/i18n.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 
 const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
 const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 792565dd96..424bcdd51f 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { initChart } from '@/utility/init-chart.js';
 
 export type InstanceForPie = {
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 708768a1e4..34c0945ddb 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue
index 8fa2c61613..6bb0918fea 100644
--- a/packages/frontend/src/pages/admin/performance.vue
+++ b/packages/frontend/src/pages/admin/performance.vue
@@ -119,7 +119,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkLink from '@/components/MkLink.vue';
-import { useForm } from '@/utility/use-form.js';
+import { useForm } from '@/use/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index 071d4b2f51..9c7a83b1fb 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, shallowRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index 85dcec6b2e..13f57b8549 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -135,7 +135,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useForm } from '@/utility/use-form.js';
+import { useForm } from '@/use/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 8c89664671..c019bda32c 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -273,7 +273,7 @@ import { definePage } from '@/page.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import { useForm } from '@/utility/use-form.js';
+import { useForm } from '@/use/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkRadios from '@/components/MkRadios.vue';
 
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index 11971756f4..5ee42cc1ec 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -92,7 +92,7 @@ import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { addTheme } from '@/theme-store.js';
 import { i18n } from '@/i18n.js';
-import { useLeaveGuard } from '@/utility/use-leave-guard.js';
+import { useLeaveGuard } from '@/use/use-leave-guard.js';
 import { definePage } from '@/page.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index a86387571a..3b5e9617d5 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index d083fdebed..af8e4d43a6 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index d5e8f45608..32e4e78d21 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -21,7 +21,7 @@ import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/utility/use-chart-tooltip.js';
+import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/use/use-chart-tooltip.ts b/packages/frontend/src/use/use-chart-tooltip.ts
new file mode 100644
index 0000000000..bba64fc6ee
--- /dev/null
+++ b/packages/frontend/src/use/use-chart-tooltip.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted, onDeactivated, ref } from 'vue';
+import * as os from '@/os.js';
+import MkChartTooltip from '@/components/MkChartTooltip.vue';
+
+export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
+	const tooltipShowing = ref(false);
+	const tooltipX = ref(0);
+	const tooltipY = ref(0);
+	const tooltipTitle = ref<string | null>(null);
+	const tooltipSeries = ref<{
+		backgroundColor: string;
+		borderColor: string;
+		text: string;
+	}[] | null>(null);
+	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
+		showing: tooltipShowing,
+		x: tooltipX,
+		y: tooltipY,
+		title: tooltipTitle,
+		series: tooltipSeries,
+	}, {});
+
+	onUnmounted(() => {
+		disposeTooltipComponent();
+	});
+
+	onDeactivated(() => {
+		tooltipShowing.value = false;
+	});
+
+	function handler(context) {
+		if (context.tooltip.opacity === 0) {
+			tooltipShowing.value = false;
+			return;
+		}
+
+		tooltipTitle.value = context.tooltip.title[0];
+		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
+			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
+			borderColor: context.tooltip.labelColors[i].borderColor,
+			text: b.lines[0],
+		}));
+
+		const rect = context.chart.canvas.getBoundingClientRect();
+
+		tooltipShowing.value = true;
+		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
+		if (opts.position === 'top') {
+			tooltipY.value = rect.top + window.scrollY;
+		} else if (opts.position === 'middle') {
+			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
+		}
+	}
+
+	return {
+		handler,
+	};
+}
diff --git a/packages/frontend/src/use/use-form.ts b/packages/frontend/src/use/use-form.ts
new file mode 100644
index 0000000000..26cca839c3
--- /dev/null
+++ b/packages/frontend/src/use/use-form.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, reactive, watch } from 'vue';
+import type { Reactive } from 'vue';
+
+function copy<T>(v: T): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+function unwrapReactive<T>(v: Reactive<T>): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
+	const currentState = reactive<T>(copy(initialState));
+	const previousState = reactive<T>(copy(initialState));
+
+	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
+	for (const key in currentState) {
+		modifiedStates[key] = false;
+	}
+	const modified = computed(() => Object.values(modifiedStates).some(v => v));
+	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
+
+	watch([currentState, previousState], () => {
+		for (const key in modifiedStates) {
+			modifiedStates[key] = currentState[key] !== previousState[key];
+		}
+	}, { deep: true });
+
+	async function _save() {
+		await save(unwrapReactive(currentState));
+		for (const key in currentState) {
+			previousState[key] = copy(currentState[key]);
+		}
+	}
+
+	function discard() {
+		for (const key in currentState) {
+			currentState[key] = copy(previousState[key]);
+		}
+	}
+
+	return {
+		state: currentState,
+		savedState: previousState,
+		modifiedStates,
+		modified,
+		modifiedCount,
+		save: _save,
+		discard,
+	};
+}
diff --git a/packages/frontend/src/use/use-leave-guard.ts b/packages/frontend/src/use/use-leave-guard.ts
new file mode 100644
index 0000000000..395c12a756
--- /dev/null
+++ b/packages/frontend/src/use/use-leave-guard.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Ref } from 'vue';
+
+export function useLeaveGuard(enabled: Ref<boolean>) {
+	/* TODO
+	const setLeaveGuard = inject('setLeaveGuard');
+
+	if (setLeaveGuard) {
+		setLeaveGuard(async () => {
+			if (!enabled.value) return false;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return canceled;
+		});
+	} else {
+		onBeforeRouteLeave(async (to, from) => {
+			if (!enabled.value) return true;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return !canceled;
+		});
+	}
+	*/
+
+	/*
+	function onBeforeLeave(ev: BeforeUnloadEvent) {
+		if (enabled.value) {
+			ev.preventDefault();
+			ev.returnValue = '';
+		}
+	}
+
+	window.addEventListener('beforeunload', onBeforeLeave);
+	onUnmounted(() => {
+		window.removeEventListener('beforeunload', onBeforeLeave);
+	});
+	*/
+}
diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts
new file mode 100644
index 0000000000..0bc10e90e4
--- /dev/null
+++ b/packages/frontend/src/use/use-note-capture.ts
@@ -0,0 +1,124 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted } from 'vue';
+import type { Ref, ShallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useStream } from '@/stream.js';
+import { $i } from '@/account.js';
+
+export function useNoteCapture(props: {
+	rootEl: ShallowRef<HTMLElement | undefined>;
+	note: Ref<Misskey.entities.Note>;
+	pureNote: Ref<Misskey.entities.Note>;
+	isDeletedRef: Ref<boolean>;
+}) {
+	const note = props.note;
+	const pureNote = props.pureNote;
+	const connection = $i ? useStream() : null;
+
+	function onStreamNoteUpdated(noteData): void {
+		const { type, id, body } = noteData;
+
+		if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
+
+		switch (type) {
+			case 'reacted': {
+				const reaction = body.reaction;
+
+				if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
+					note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
+				}
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = currentCount + 1;
+				note.value.reactionCount += 1;
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = reaction;
+				}
+				break;
+			}
+
+			case 'unreacted': {
+				const reaction = body.reaction;
+
+				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
+				const currentCount = (note.value.reactions || {})[reaction] || 0;
+
+				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
+				note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
+				if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
+
+				if ($i && (body.userId === $i.id)) {
+					note.value.myReaction = null;
+				}
+				break;
+			}
+
+			case 'pollVoted': {
+				const choice = body.choice;
+
+				const choices = [...note.value.poll.choices];
+				choices[choice] = {
+					...choices[choice],
+					votes: choices[choice].votes + 1,
+					...($i && (body.userId === $i.id) ? {
+						isVoted: true,
+					} : {}),
+				};
+
+				note.value.poll.choices = choices;
+				break;
+			}
+
+			case 'deleted': {
+				props.isDeletedRef.value = true;
+				break;
+			}
+		}
+	}
+
+	function capture(withHandler = false): void {
+		if (connection) {
+			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+			connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
+			if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
+			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function decapture(withHandler = false): void {
+		if (connection) {
+			connection.send('un', {
+				id: note.value.id,
+			});
+			if (pureNote.value.id !== note.value.id) {
+				connection.send('un', {
+					id: pureNote.value.id,
+				});
+			}
+			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+		}
+	}
+
+	function onStreamConnected() {
+		capture(false);
+	}
+
+	capture(true);
+	if (connection) {
+		connection.on('_connected_', onStreamConnected);
+	}
+
+	onUnmounted(() => {
+		decapture(true);
+		if (connection) {
+			connection.off('_connected_', onStreamConnected);
+		}
+	});
+}
diff --git a/packages/frontend/src/use/use-tooltip.ts b/packages/frontend/src/use/use-tooltip.ts
new file mode 100644
index 0000000000..d9ddfc8b5d
--- /dev/null
+++ b/packages/frontend/src/use/use-tooltip.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, watch, onUnmounted } from 'vue';
+import type { Ref } from 'vue';
+
+export function useTooltip(
+	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
+	onShow: (showing: Ref<boolean>) => void,
+	delay = 300,
+): void {
+	let isHovering = false;
+
+	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
+	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
+	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
+	let shouldIgnoreMouseover = false;
+
+	let timeoutId: number;
+
+	let changeShowingState: (() => void) | null;
+
+	let autoHidingTimer;
+
+	const open = () => {
+		close();
+		if (!isHovering) return;
+		if (elRef.value == null) return;
+		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
+
+		const showing = ref(true);
+		onShow(showing);
+		changeShowingState = () => {
+			showing.value = false;
+		};
+
+		autoHidingTimer = window.setInterval(() => {
+			if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
+				if (!isHovering) return;
+				isHovering = false;
+				window.clearTimeout(timeoutId);
+				close();
+				window.clearInterval(autoHidingTimer);
+			}
+		}, 1000);
+	};
+
+	const close = () => {
+		if (changeShowingState != null) {
+			changeShowingState();
+			changeShowingState = null;
+		}
+	};
+
+	const onMouseover = () => {
+		if (isHovering) return;
+		if (shouldIgnoreMouseover) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onMouseleave = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const onTouchstart = () => {
+		shouldIgnoreMouseover = true;
+		if (isHovering) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onTouchend = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const stop = watch(elRef, () => {
+		if (elRef.value) {
+			stop();
+			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+			el.addEventListener('mouseover', onMouseover, { passive: true });
+			el.addEventListener('mouseleave', onMouseleave, { passive: true });
+			el.addEventListener('touchstart', onTouchstart, { passive: true });
+			el.addEventListener('touchend', onTouchend, { passive: true });
+			el.addEventListener('click', close, { passive: true });
+		}
+	}, {
+		immediate: true,
+		flush: 'post',
+	});
+
+	onUnmounted(() => {
+		close();
+	});
+}
diff --git a/packages/frontend/src/utility/use-chart-tooltip.ts b/packages/frontend/src/utility/use-chart-tooltip.ts
deleted file mode 100644
index bba64fc6ee..0000000000
--- a/packages/frontend/src/utility/use-chart-tooltip.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted, onDeactivated, ref } from 'vue';
-import * as os from '@/os.js';
-import MkChartTooltip from '@/components/MkChartTooltip.vue';
-
-export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
-	const tooltipShowing = ref(false);
-	const tooltipX = ref(0);
-	const tooltipY = ref(0);
-	const tooltipTitle = ref<string | null>(null);
-	const tooltipSeries = ref<{
-		backgroundColor: string;
-		borderColor: string;
-		text: string;
-	}[] | null>(null);
-	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
-		showing: tooltipShowing,
-		x: tooltipX,
-		y: tooltipY,
-		title: tooltipTitle,
-		series: tooltipSeries,
-	}, {});
-
-	onUnmounted(() => {
-		disposeTooltipComponent();
-	});
-
-	onDeactivated(() => {
-		tooltipShowing.value = false;
-	});
-
-	function handler(context) {
-		if (context.tooltip.opacity === 0) {
-			tooltipShowing.value = false;
-			return;
-		}
-
-		tooltipTitle.value = context.tooltip.title[0];
-		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
-			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
-			borderColor: context.tooltip.labelColors[i].borderColor,
-			text: b.lines[0],
-		}));
-
-		const rect = context.chart.canvas.getBoundingClientRect();
-
-		tooltipShowing.value = true;
-		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
-		if (opts.position === 'top') {
-			tooltipY.value = rect.top + window.scrollY;
-		} else if (opts.position === 'middle') {
-			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
-		}
-	}
-
-	return {
-		handler,
-	};
-}
diff --git a/packages/frontend/src/utility/use-form.ts b/packages/frontend/src/utility/use-form.ts
deleted file mode 100644
index 26cca839c3..0000000000
--- a/packages/frontend/src/utility/use-form.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, reactive, watch } from 'vue';
-import type { Reactive } from 'vue';
-
-function copy<T>(v: T): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-function unwrapReactive<T>(v: Reactive<T>): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
-	const currentState = reactive<T>(copy(initialState));
-	const previousState = reactive<T>(copy(initialState));
-
-	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
-	for (const key in currentState) {
-		modifiedStates[key] = false;
-	}
-	const modified = computed(() => Object.values(modifiedStates).some(v => v));
-	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
-
-	watch([currentState, previousState], () => {
-		for (const key in modifiedStates) {
-			modifiedStates[key] = currentState[key] !== previousState[key];
-		}
-	}, { deep: true });
-
-	async function _save() {
-		await save(unwrapReactive(currentState));
-		for (const key in currentState) {
-			previousState[key] = copy(currentState[key]);
-		}
-	}
-
-	function discard() {
-		for (const key in currentState) {
-			currentState[key] = copy(previousState[key]);
-		}
-	}
-
-	return {
-		state: currentState,
-		savedState: previousState,
-		modifiedStates,
-		modified,
-		modifiedCount,
-		save: _save,
-		discard,
-	};
-}
diff --git a/packages/frontend/src/utility/use-leave-guard.ts b/packages/frontend/src/utility/use-leave-guard.ts
deleted file mode 100644
index 395c12a756..0000000000
--- a/packages/frontend/src/utility/use-leave-guard.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Ref } from 'vue';
-
-export function useLeaveGuard(enabled: Ref<boolean>) {
-	/* TODO
-	const setLeaveGuard = inject('setLeaveGuard');
-
-	if (setLeaveGuard) {
-		setLeaveGuard(async () => {
-			if (!enabled.value) return false;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return canceled;
-		});
-	} else {
-		onBeforeRouteLeave(async (to, from) => {
-			if (!enabled.value) return true;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return !canceled;
-		});
-	}
-	*/
-
-	/*
-	function onBeforeLeave(ev: BeforeUnloadEvent) {
-		if (enabled.value) {
-			ev.preventDefault();
-			ev.returnValue = '';
-		}
-	}
-
-	window.addEventListener('beforeunload', onBeforeLeave);
-	onUnmounted(() => {
-		window.removeEventListener('beforeunload', onBeforeLeave);
-	});
-	*/
-}
diff --git a/packages/frontend/src/utility/use-note-capture.ts b/packages/frontend/src/utility/use-note-capture.ts
deleted file mode 100644
index 0bc10e90e4..0000000000
--- a/packages/frontend/src/utility/use-note-capture.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
-import * as Misskey from 'misskey-js';
-import { useStream } from '@/stream.js';
-import { $i } from '@/account.js';
-
-export function useNoteCapture(props: {
-	rootEl: ShallowRef<HTMLElement | undefined>;
-	note: Ref<Misskey.entities.Note>;
-	pureNote: Ref<Misskey.entities.Note>;
-	isDeletedRef: Ref<boolean>;
-}) {
-	const note = props.note;
-	const pureNote = props.pureNote;
-	const connection = $i ? useStream() : null;
-
-	function onStreamNoteUpdated(noteData): void {
-		const { type, id, body } = noteData;
-
-		if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
-
-		switch (type) {
-			case 'reacted': {
-				const reaction = body.reaction;
-
-				if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
-					note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
-				}
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = currentCount + 1;
-				note.value.reactionCount += 1;
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = reaction;
-				}
-				break;
-			}
-
-			case 'unreacted': {
-				const reaction = body.reaction;
-
-				// TODO: reactionsプロパティがない場合ってあったっけ？ なければ || {} は消せる
-				const currentCount = (note.value.reactions || {})[reaction] || 0;
-
-				note.value.reactions[reaction] = Math.max(0, currentCount - 1);
-				note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
-				if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
-
-				if ($i && (body.userId === $i.id)) {
-					note.value.myReaction = null;
-				}
-				break;
-			}
-
-			case 'pollVoted': {
-				const choice = body.choice;
-
-				const choices = [...note.value.poll.choices];
-				choices[choice] = {
-					...choices[choice],
-					votes: choices[choice].votes + 1,
-					...($i && (body.userId === $i.id) ? {
-						isVoted: true,
-					} : {}),
-				};
-
-				note.value.poll.choices = choices;
-				break;
-			}
-
-			case 'deleted': {
-				props.isDeletedRef.value = true;
-				break;
-			}
-		}
-	}
-
-	function capture(withHandler = false): void {
-		if (connection) {
-			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-			connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
-			if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
-			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function decapture(withHandler = false): void {
-		if (connection) {
-			connection.send('un', {
-				id: note.value.id,
-			});
-			if (pureNote.value.id !== note.value.id) {
-				connection.send('un', {
-					id: pureNote.value.id,
-				});
-			}
-			if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
-		}
-	}
-
-	function onStreamConnected() {
-		capture(false);
-	}
-
-	capture(true);
-	if (connection) {
-		connection.on('_connected_', onStreamConnected);
-	}
-
-	onUnmounted(() => {
-		decapture(true);
-		if (connection) {
-			connection.off('_connected_', onStreamConnected);
-		}
-	});
-}
diff --git a/packages/frontend/src/utility/use-tooltip.ts b/packages/frontend/src/utility/use-tooltip.ts
deleted file mode 100644
index d9ddfc8b5d..0000000000
--- a/packages/frontend/src/utility/use-tooltip.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref, watch, onUnmounted } from 'vue';
-import type { Ref } from 'vue';
-
-export function useTooltip(
-	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
-	onShow: (showing: Ref<boolean>) => void,
-	delay = 300,
-): void {
-	let isHovering = false;
-
-	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
-	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
-	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
-	let shouldIgnoreMouseover = false;
-
-	let timeoutId: number;
-
-	let changeShowingState: (() => void) | null;
-
-	let autoHidingTimer;
-
-	const open = () => {
-		close();
-		if (!isHovering) return;
-		if (elRef.value == null) return;
-		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
-
-		const showing = ref(true);
-		onShow(showing);
-		changeShowingState = () => {
-			showing.value = false;
-		};
-
-		autoHidingTimer = window.setInterval(() => {
-			if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
-				if (!isHovering) return;
-				isHovering = false;
-				window.clearTimeout(timeoutId);
-				close();
-				window.clearInterval(autoHidingTimer);
-			}
-		}, 1000);
-	};
-
-	const close = () => {
-		if (changeShowingState != null) {
-			changeShowingState();
-			changeShowingState = null;
-		}
-	};
-
-	const onMouseover = () => {
-		if (isHovering) return;
-		if (shouldIgnoreMouseover) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onMouseleave = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const onTouchstart = () => {
-		shouldIgnoreMouseover = true;
-		if (isHovering) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onTouchend = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const stop = watch(elRef, () => {
-		if (elRef.value) {
-			stop();
-			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-			el.addEventListener('mouseover', onMouseover, { passive: true });
-			el.addEventListener('mouseleave', onMouseleave, { passive: true });
-			el.addEventListener('touchstart', onTouchstart, { passive: true });
-			el.addEventListener('touchend', onTouchend, { passive: true });
-			el.addEventListener('click', close, { passive: true });
-		}
-	}, {
-		immediate: true,
-		flush: 'post',
-	});
-
-	onUnmounted(() => {
-		close();
-	});
-}
-- 
cgit v1.2.3-freya


From 8c9ec5827fa2040c8d705d2a01329da593d19fa3 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 13 Mar 2025 22:12:23 +0900
Subject: enhance(frontend): improve accounts management

---
 packages/frontend/src/account.ts                   | 390 ---------------------
 packages/frontend/src/accounts.ts                  | 341 ++++++++++++++++++
 packages/frontend/src/aiscript/api.ts              |   2 +-
 packages/frontend/src/boot/common.ts               |  10 +-
 packages/frontend/src/boot/main-boot.ts            |  24 +-
 .../src/components/MkAnnouncementDialog.vue        |   5 +-
 packages/frontend/src/components/MkAuthConfirm.vue |   9 +-
 packages/frontend/src/components/MkClipPreview.vue |   2 +-
 .../frontend/src/components/MkCropperDialog.vue    |   2 +-
 packages/frontend/src/components/MkDrive.file.vue  |   2 +-
 packages/frontend/src/components/MkEmojiPicker.vue |   2 +-
 .../frontend/src/components/MkFollowButton.vue     |   2 +-
 .../frontend/src/components/MkInstanceStats.vue    |   2 +-
 packages/frontend/src/components/MkMediaAudio.vue  |   2 +-
 packages/frontend/src/components/MkMediaImage.vue  |   2 +-
 packages/frontend/src/components/MkMediaVideo.vue  |   2 +-
 packages/frontend/src/components/MkMention.vue     |   2 +-
 packages/frontend/src/components/MkNote.vue        |   2 +-
 .../frontend/src/components/MkNoteDetailed.vue     |   2 +-
 packages/frontend/src/components/MkNoteSub.vue     |   2 +-
 .../frontend/src/components/MkNotification.vue     |   2 +-
 .../frontend/src/components/MkPasswordDialog.vue   |   2 +-
 packages/frontend/src/components/MkPostForm.vue    |   5 +-
 packages/frontend/src/components/MkPreview.vue     |   2 +-
 .../components/MkPushNotificationAllowButton.vue   |   3 +-
 .../src/components/MkReactionsViewer.reaction.vue  |   2 +-
 packages/frontend/src/components/MkSignin.vue      |   5 +-
 .../src/components/MkSignupDialog.form.vue         |   4 +-
 packages/frontend/src/components/MkTimeline.vue    |   2 +-
 .../src/components/MkTokenGenerateWindow.vue       |   2 +-
 .../src/components/MkTutorialDialog.Note.vue       |   2 +-
 .../src/components/MkTutorialDialog.Sensitive.vue  |   2 +-
 packages/frontend/src/components/MkUserInfo.vue    |   2 +-
 packages/frontend/src/components/MkUserPopup.vue   |   2 +-
 .../frontend/src/components/MkUserSelectDialog.vue |   2 +-
 .../src/components/MkUserSetupDialog.Profile.vue   |   2 +-
 packages/frontend/src/components/global/MkAd.vue   |   2 +-
 .../src/components/global/MkCustomEmoji.vue        |   2 +-
 .../src/components/global/MkPageHeader.vue         |   3 +-
 packages/frontend/src/i.ts                         |  34 ++
 packages/frontend/src/local-storage.ts             |   1 -
 packages/frontend/src/navbar.ts                    |   2 +-
 packages/frontend/src/pages/about-misskey.vue      |   2 +-
 packages/frontend/src/pages/about.emojis.vue       |   2 +-
 packages/frontend/src/pages/achievements.vue       |   2 +-
 packages/frontend/src/pages/admin-file.vue         |   2 +-
 packages/frontend/src/pages/admin-user.vue         |   2 +-
 packages/frontend/src/pages/announcement.vue       |   5 +-
 packages/frontend/src/pages/announcements.vue      |   5 +-
 packages/frontend/src/pages/auth.vue               |   3 +-
 .../src/pages/avatar-decoration-edit-dialog.vue    |   2 +-
 packages/frontend/src/pages/avatar-decorations.vue |   2 +-
 packages/frontend/src/pages/channel.vue            |   2 +-
 packages/frontend/src/pages/clip.vue               |   2 +-
 .../frontend/src/pages/drop-and-fusion.game.vue    |   2 +-
 packages/frontend/src/pages/emojis.emoji.vue       |   2 +-
 packages/frontend/src/pages/flash/flash.vue        |   2 +-
 packages/frontend/src/pages/follow-requests.vue    |   2 +-
 packages/frontend/src/pages/gallery/post.vue       |   2 +-
 packages/frontend/src/pages/instance-info.vue      |   2 +-
 packages/frontend/src/pages/invite.vue             |   2 +-
 packages/frontend/src/pages/my-lists/index.vue     |   2 +-
 packages/frontend/src/pages/my-lists/list.vue      |   2 +-
 packages/frontend/src/pages/note.vue               |   2 +-
 .../frontend/src/pages/page-editor/page-editor.vue |   2 +-
 packages/frontend/src/pages/page.vue               |   2 +-
 packages/frontend/src/pages/reversi/game.board.vue |   2 +-
 .../frontend/src/pages/reversi/game.setting.vue    |   2 +-
 packages/frontend/src/pages/reversi/game.vue       |   2 +-
 packages/frontend/src/pages/reversi/index.vue      |   2 +-
 packages/frontend/src/pages/scratchpad.vue         |   2 +-
 packages/frontend/src/pages/search.note.vue        |   2 +-
 .../frontend/src/pages/settings/2fa.qrdialog.vue   |   2 +-
 packages/frontend/src/pages/settings/2fa.vue       |   5 +-
 .../frontend/src/pages/settings/account-data.vue   |   2 +-
 packages/frontend/src/pages/settings/accounts.vue  | 103 ++----
 .../settings/avatar-decoration.decoration.vue      |   2 +-
 .../pages/settings/avatar-decoration.dialog.vue    |   2 +-
 .../src/pages/settings/avatar-decoration.vue       |   2 +-
 packages/frontend/src/pages/settings/drive.vue     |   2 +-
 packages/frontend/src/pages/settings/email.vue     |   2 +-
 packages/frontend/src/pages/settings/index.vue     |   3 +-
 packages/frontend/src/pages/settings/migration.vue |   2 +-
 .../pages/settings/mute-block.instance-mute.vue    |   2 +-
 .../frontend/src/pages/settings/mute-block.vue     |   2 +-
 .../frontend/src/pages/settings/notifications.vue  |   2 +-
 packages/frontend/src/pages/settings/other.vue     |   3 +-
 packages/frontend/src/pages/settings/privacy.vue   |   2 +-
 packages/frontend/src/pages/settings/profile.vue   |   2 +-
 packages/frontend/src/pages/signup-complete.vue    |   2 +-
 packages/frontend/src/pages/tag.vue                |   2 +-
 packages/frontend/src/pages/theme-editor.vue       |   2 +-
 packages/frontend/src/pages/timeline.vue           |   2 +-
 packages/frontend/src/pages/user/achievements.vue  |   2 +-
 packages/frontend/src/pages/user/home.vue          |   2 +-
 packages/frontend/src/pages/user/index.vue         |   2 +-
 packages/frontend/src/pages/welcome.setup.vue      |   2 +-
 packages/frontend/src/pizzax.ts                    |   2 +-
 packages/frontend/src/preferences.ts               |   2 +-
 packages/frontend/src/preferences/def.ts           |   4 +
 packages/frontend/src/preferences/manager.ts       |   2 +-
 packages/frontend/src/preferences/utility.ts       |   2 +-
 packages/frontend/src/router/definition.ts         |   2 +-
 packages/frontend/src/signout.ts                   |  54 +++
 packages/frontend/src/store.ts                     |   4 +
 packages/frontend/src/stream.ts                    |   2 +-
 packages/frontend/src/theme-store.ts               |   2 +-
 packages/frontend/src/timelines.ts                 |   2 +-
 .../frontend/src/ui/_common_/PreferenceRestore.vue |   2 +-
 .../frontend/src/ui/_common_/announcements.vue     |   2 +-
 packages/frontend/src/ui/_common_/common.ts        |   2 +-
 packages/frontend/src/ui/_common_/common.vue       |   2 +-
 .../frontend/src/ui/_common_/navbar-for-mobile.vue |   3 +-
 packages/frontend/src/ui/_common_/navbar.vue       |   3 +-
 packages/frontend/src/ui/_common_/sw-inject.ts     |   3 +-
 packages/frontend/src/ui/classic.header.vue        |   4 +-
 packages/frontend/src/ui/classic.sidebar.vue       |   3 +-
 packages/frontend/src/ui/deck.vue                  |   2 +-
 packages/frontend/src/ui/universal.vue             |   2 +-
 packages/frontend/src/use/use-note-capture.ts      |   2 +-
 packages/frontend/src/utility/achievements.ts      |   2 +-
 .../src/utility/autogen/settings-search-index.ts   |   7 +
 packages/frontend/src/utility/check-permissions.ts |   2 +-
 packages/frontend/src/utility/get-note-menu.ts     |   2 +-
 packages/frontend/src/utility/get-user-menu.ts     |   2 +-
 packages/frontend/src/utility/isFfVisibleForMe.ts  |   2 +-
 packages/frontend/src/utility/misskey-api.ts       |   2 +-
 packages/frontend/src/utility/please-login.ts      |   2 +-
 packages/frontend/src/utility/show-moved-dialog.ts |   2 +-
 packages/frontend/src/utility/upload.ts            |   2 +-
 packages/frontend/src/widgets/WidgetActivity.vue   |   2 +-
 packages/frontend/src/widgets/WidgetAiscript.vue   |   2 +-
 .../frontend/src/widgets/WidgetAiscriptApp.vue     |   2 +-
 .../src/widgets/WidgetBirthdayFollowings.vue       |   2 +-
 packages/frontend/src/widgets/WidgetButton.vue     |   2 +-
 packages/frontend/src/widgets/WidgetProfile.vue    |   2 +-
 packages/frontend/test/aiscript/api.test.ts        |   2 +-
 137 files changed, 640 insertions(+), 622 deletions(-)
 delete mode 100644 packages/frontend/src/account.ts
 create mode 100644 packages/frontend/src/accounts.ts
 create mode 100644 packages/frontend/src/i.ts
 create mode 100644 packages/frontend/src/signout.ts

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
deleted file mode 100644
index c90d4da5ec..0000000000
--- a/packages/frontend/src/account.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent, reactive, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { apiUrl } from '@@/js/config.js';
-import type { MenuItem, MenuButton } from '@/types/menu.js';
-import { defaultMemoryStorage } from '@/memory-storage';
-import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
-import { i18n } from '@/i18n.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { del, get, set } from '@/utility/idb-proxy.js';
-import { waiting, popup, popupMenu, success, alert } from '@/os.js';
-import { misskeyApi } from '@/utility/misskey-api.js';
-import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
-
-// TODO: 他のタブと永続化されたstateを同期
-// TODO: accountsはpreferences管理にする(tokenは別管理)
-
-type Account = Misskey.entities.MeDetailed & { token: string };
-
-const accountData = miLocalStorage.getItem('account');
-
-// TODO: 外部からはreadonlyに
-export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
-
-export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true);
-export const iAmAdmin = $i != null && $i.isAdmin;
-
-export function signinRequired() {
-	if ($i == null) throw new Error('signin required');
-	return $i;
-}
-
-export let notesCount = $i == null ? 0 : $i.notesCount;
-export function incNotesCount() {
-	notesCount++;
-}
-
-export async function signout() {
-	if (!$i) return;
-
-	defaultMemoryStorage.clear();
-
-	waiting();
-	document.cookie.split(';').forEach((cookie) => {
-		const cookieName = cookie.split('=')[0].trim();
-		if (cookieName === 'token') {
-			document.cookie = `${cookieName}=; max-age=0; path=/`;
-		}
-	});
-	miLocalStorage.removeItem('account');
-	await removeAccount($i.id);
-	const accounts = await getAccounts();
-
-	//#region Remove service worker registration
-	try {
-		if (navigator.serviceWorker.controller) {
-			const registration = await navigator.serviceWorker.ready;
-			const push = await registration.pushManager.getSubscription();
-			if (push) {
-				await window.fetch(`${apiUrl}/sw/unregister`, {
-					method: 'POST',
-					body: JSON.stringify({
-						i: $i.token,
-						endpoint: push.endpoint,
-					}),
-					headers: {
-						'Content-Type': 'application/json',
-					},
-				});
-			}
-		}
-
-		if (accounts.length === 0) {
-			await navigator.serviceWorker.getRegistrations()
-				.then(registrations => {
-					return Promise.all(registrations.map(registration => registration.unregister()));
-				});
-		}
-	} catch (err) {}
-	//#endregion
-
-	if (accounts.length > 0) login(accounts[0].token);
-	else unisonReload('/');
-}
-
-export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> {
-	return (await get('accounts')) || [];
-}
-
-export async function addAccount(id: Account['id'], token: Account['token']) {
-	const accounts = await getAccounts();
-	if (!accounts.some(x => x.id === id)) {
-		await set('accounts', accounts.concat([{ id, token }]));
-	}
-}
-
-export async function removeAccount(idOrToken: Account['id']) {
-	const accounts = await getAccounts();
-	const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken);
-	if (i !== -1) accounts.splice(i, 1);
-
-	if (accounts.length > 0) {
-		await set('accounts', accounts);
-	} else {
-		await del('accounts');
-	}
-}
-
-function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
-	document.cookie = 'token=; path=/; max-age=0';
-	document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う
-
-	return new Promise((done, fail) => {
-		window.fetch(`${apiUrl}/i`, {
-			method: 'POST',
-			body: JSON.stringify({
-				i: token,
-			}),
-			headers: {
-				'Content-Type': 'application/json',
-			},
-		})
-			.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
-				if (res.status >= 500 && res.status < 600) {
-					// サーバーエラー(5xx)の場合をrejectとする
-					// （認証エラーなど4xxはresolve）
-					return fail2(res);
-				}
-				res.json().then(done2, fail2);
-			}))
-			.then(async res => {
-				if ('error' in res) {
-					if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
-						// SUSPENDED
-						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
-							await showSuspendedDialog();
-						}
-					} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
-						// USER_IS_DELETED
-						// アカウントが削除されている
-						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
-							await alert({
-								type: 'error',
-								title: i18n.ts.accountDeleted,
-								text: i18n.ts.accountDeletedDescription,
-							});
-						}
-					} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
-						// AUTHENTICATION_FAILED
-						// トークンが無効化されていたりアカウントが削除されたりしている
-						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
-							await alert({
-								type: 'error',
-								title: i18n.ts.tokenRevoked,
-								text: i18n.ts.tokenRevokedDescription,
-							});
-						}
-					} else {
-						await alert({
-							type: 'error',
-							title: i18n.ts.failedToFetchAccountInformation,
-							text: JSON.stringify(res.error),
-						});
-					}
-
-					// rejectかつ理由がtrueの場合、削除対象であることを示す
-					fail(true);
-				} else {
-					(res as Account).token = token;
-					done(res as Account);
-				}
-			})
-			.catch(fail);
-	});
-}
-
-export function updateAccount(accountData: Account) {
-	if (!$i) return;
-	for (const key of Object.keys($i)) {
-		delete $i[key];
-	}
-	for (const [key, value] of Object.entries(accountData)) {
-		$i[key] = value;
-	}
-	miLocalStorage.setItem('account', JSON.stringify($i));
-}
-
-export function updateAccountPartial(accountData: Partial<Account>) {
-	if (!$i) return;
-	for (const [key, value] of Object.entries(accountData)) {
-		$i[key] = value;
-	}
-	miLocalStorage.setItem('account', JSON.stringify($i));
-}
-
-export async function refreshAccount() {
-	if (!$i) return;
-	return fetchAccount($i.token, $i.id)
-		.then(updateAccount, reason => {
-			if (reason === true) return signout();
-			return;
-		});
-}
-
-export async function login(token: Account['token'], redirect?: string) {
-	const showing = ref(true);
-	const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
-		success: false,
-		showing: showing,
-	}, {
-		closed: () => dispose(),
-	});
-	if (_DEV_) console.log('logging as token ', token);
-	const me = await fetchAccount(token, undefined, true)
-		.catch(reason => {
-			if (reason === true) {
-				// 削除対象の場合
-				removeAccount(token);
-			}
-
-			showing.value = false;
-			throw reason;
-		});
-	miLocalStorage.setItem('account', JSON.stringify(me));
-	await addAccount(me.id, token);
-
-	if (redirect) {
-		// 他のタブは再読み込みするだけ
-		reloadChannel.postMessage(null);
-		// このページはredirectで指定された先に移動
-		location.href = redirect;
-		return;
-	}
-
-	unisonReload();
-}
-
-export async function openAccountMenu(opts: {
-	includeCurrentAccount?: boolean;
-	withExtraOperation: boolean;
-	active?: Misskey.entities.UserDetailed['id'];
-	onChoose?: (account: Misskey.entities.UserDetailed) => void;
-}, ev: MouseEvent) {
-	if (!$i) return;
-
-	async function switchAccount(account: Misskey.entities.UserDetailed) {
-		const storedAccounts = await getAccounts();
-		const found = storedAccounts.find(x => x.id === account.id);
-		if (found == null) return;
-		switchAccountWithToken(found.token);
-	}
-
-	function switchAccountWithToken(token: string) {
-		login(token);
-	}
-
-	const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
-	const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) });
-
-	function createItem(account: Misskey.entities.UserDetailed) {
-		return {
-			type: 'user' as const,
-			user: account,
-			active: opts.active != null ? opts.active === account.id : false,
-			action: () => {
-				if (opts.onChoose) {
-					opts.onChoose(account);
-				} else {
-					switchAccount(account);
-				}
-			},
-		};
-	}
-
-	const accountItemPromises = storedAccounts.map(a => new Promise<ReturnType<typeof createItem> | MenuButton>(res => {
-		accountsPromise.then(accounts => {
-			const account = accounts.find(x => x.id === a.id);
-			if (account == null) return res({
-				type: 'button' as const,
-				text: a.id,
-				action: () => {
-					switchAccountWithToken(a.token);
-				},
-			});
-
-			res(createItem(account));
-		});
-	}));
-
-	const menuItems: MenuItem[] = [];
-
-	if (opts.withExtraOperation) {
-		menuItems.push({
-			type: 'link',
-			text: i18n.ts.profile,
-			to: `/@${$i.username}`,
-			avatar: $i,
-		}, {
-			type: 'divider',
-		});
-
-		if (opts.includeCurrentAccount) {
-			menuItems.push(createItem($i));
-		}
-
-		menuItems.push(...accountItemPromises);
-
-		menuItems.push({
-			type: 'parent',
-			icon: 'ti ti-plus',
-			text: i18n.ts.addAccount,
-			children: [{
-				text: i18n.ts.existingAccount,
-				action: () => {
-					getAccountWithSigninDialog().then(res => {
-						if (res != null) {
-							success();
-						}
-					});
-				},
-			}, {
-				text: i18n.ts.createAccount,
-				action: () => {
-					getAccountWithSignupDialog().then(res => {
-						if (res != null) {
-							switchAccountWithToken(res.token);
-						}
-					});
-				},
-			}],
-		}, {
-			type: 'link',
-			icon: 'ti ti-users',
-			text: i18n.ts.manageAccounts,
-			to: '/settings/accounts',
-		});
-	} else {
-		if (opts.includeCurrentAccount) {
-			menuItems.push(createItem($i));
-		}
-
-		menuItems.push(...accountItemPromises);
-	}
-
-	popupMenu(menuItems, ev.currentTarget ?? ev.target, {
-		align: 'left',
-	});
-}
-
-export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
-	return new Promise((resolve) => {
-		const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
-			done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
-				await addAccount(res.id, res.i);
-				resolve({ id: res.id, token: res.i });
-			},
-			cancelled: () => {
-				resolve(null);
-			},
-			closed: () => {
-				dispose();
-			},
-		});
-	});
-}
-
-export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
-	return new Promise((resolve) => {
-		const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
-			done: async (res: Misskey.entities.SignupResponse) => {
-				await addAccount(res.id, res.token);
-				resolve({ id: res.id, token: res.token });
-			},
-			cancelled: () => {
-				resolve(null);
-			},
-			closed: () => {
-				dispose();
-			},
-		});
-	});
-}
-
-if (_DEV_) {
-	(window as any).$i = $i;
-}
diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts
new file mode 100644
index 0000000000..2382a8ec32
--- /dev/null
+++ b/packages/frontend/src/accounts.ts
@@ -0,0 +1,341 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { apiUrl, host } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
+import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
+import { i18n } from '@/i18n.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { waiting, popup, popupMenu, success, alert } from '@/os.js';
+import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import { $i } from '@/i.js';
+import { signout } from '@/signout.js';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
+
+export async function getAccounts(): Promise<{
+	host: string;
+	user: Misskey.entities.User;
+	token: string | null;
+}[]> {
+	const tokens = store.s.accountTokens;
+	const accounts = prefer.s.accounts;
+	return accounts.map(([host, user]) => ({
+		host,
+		user,
+		token: tokens[host + '/' + user.id] ?? null,
+	}));
+}
+
+async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
+	if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
+		store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
+		prefer.commit('accounts', [...prefer.s.accounts, [host, user]]);
+	}
+}
+
+export async function removeAccount(host: string, id: AccountWithToken['id']) {
+	const tokens = JSON.parse(JSON.stringify(store.s.accountTokens));
+	delete tokens[host + '/' + id];
+	store.set('accountTokens', tokens);
+	prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id));
+}
+
+const isAccountDeleted = Symbol('isAccountDeleted');
+
+function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Misskey.entities.MeDetailed> {
+	return new Promise((done, fail) => {
+		window.fetch(`${apiUrl}/i`, {
+			method: 'POST',
+			body: JSON.stringify({
+				i: token,
+			}),
+			headers: {
+				'Content-Type': 'application/json',
+			},
+		})
+			.then(res => new Promise<Misskey.entities.MeDetailed | { error: Record<string, any> }>((done2, fail2) => {
+				if (res.status >= 500 && res.status < 600) {
+					// サーバーエラー(5xx)の場合をrejectとする
+					// （認証エラーなど4xxはresolve）
+					return fail2(res);
+				}
+				res.json().then(done2, fail2);
+			}))
+			.then(async res => {
+				if ('error' in res) {
+					if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
+						// SUSPENDED
+						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+							await showSuspendedDialog();
+						}
+					} else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
+						// USER_IS_DELETED
+						// アカウントが削除されている
+						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+							await alert({
+								type: 'error',
+								title: i18n.ts.accountDeleted,
+								text: i18n.ts.accountDeletedDescription,
+							});
+						}
+					} else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
+						// AUTHENTICATION_FAILED
+						// トークンが無効化されていたりアカウントが削除されたりしている
+						if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+							await alert({
+								type: 'error',
+								title: i18n.ts.tokenRevoked,
+								text: i18n.ts.tokenRevokedDescription,
+							});
+						}
+					} else {
+						await alert({
+							type: 'error',
+							title: i18n.ts.failedToFetchAccountInformation,
+							text: JSON.stringify(res.error),
+						});
+					}
+
+					fail(isAccountDeleted);
+				} else {
+					done(res);
+				}
+			})
+			.catch(fail);
+	});
+}
+
+export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
+	if (!$i) return;
+	const token = $i.token;
+	for (const key of Object.keys($i)) {
+		delete $i[key];
+	}
+	for (const [key, value] of Object.entries(accountData)) {
+		$i[key] = value;
+	}
+	prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
+		// TODO: $iのホストも比較したいけど通常null
+		if (user.id === $i.id) {
+			return [host, $i];
+		} else {
+			return [host, user];
+		}
+	}));
+	$i.token = token;
+	miLocalStorage.setItem('account', JSON.stringify($i));
+}
+
+export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) {
+	if (!$i) return;
+	for (const [key, value] of Object.entries(accountData)) {
+		$i[key] = value;
+	}
+	prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
+		// TODO: $iのホストも比較したいけど通常null
+		if (user.id === $i.id) {
+			const newUser = JSON.parse(JSON.stringify($i));
+			for (const [key, value] of Object.entries(accountData)) {
+				newUser[key] = value;
+			}
+			return [host, newUser];
+		}
+		return [host, user];
+	}));
+	miLocalStorage.setItem('account', JSON.stringify($i));
+}
+
+export async function refreshCurrentAccount() {
+	if (!$i) return;
+	return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => {
+		if (reason === isAccountDeleted) {
+			removeAccount(host, $i.id);
+			if (Object.keys(store.s.accountTokens).length > 0) {
+				login(Object.values(store.s.accountTokens)[0]);
+			} else {
+				signout();
+			}
+		}
+	});
+}
+
+export async function login(token: AccountWithToken['token'], redirect?: string) {
+	const showing = ref(true);
+	const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
+		success: false,
+		showing: showing,
+	}, {
+		closed: () => dispose(),
+	});
+
+	const me = await fetchAccount(token, undefined, true).catch(reason => {
+		showing.value = false;
+		throw reason;
+	});
+
+	miLocalStorage.setItem('account', JSON.stringify({
+		...me,
+		token,
+	}));
+
+	await addAccount(host, me, token);
+
+	if (redirect) {
+		// 他のタブは再読み込みするだけ
+		reloadChannel.postMessage(null);
+		// このページはredirectで指定された先に移動
+		location.href = redirect;
+		return;
+	}
+
+	unisonReload();
+}
+
+export async function switchAccount(host: string, id: string) {
+	const token = store.s.accountTokens[host + '/' + id];
+	if (token) {
+		login(token);
+	} else {
+		const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+			done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
+				store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i });
+				login(res.i);
+			},
+			closed: () => {
+				dispose();
+			},
+		});
+	}
+}
+
+export async function openAccountMenu(opts: {
+	includeCurrentAccount?: boolean;
+	withExtraOperation: boolean;
+	active?: Misskey.entities.User['id'];
+	onChoose?: (account: Misskey.entities.User) => void;
+}, ev: MouseEvent) {
+	if (!$i) return;
+
+	function createItem(host: string, account: Misskey.entities.User): MenuItem {
+		return {
+			type: 'user' as const,
+			user: account,
+			active: opts.active != null ? opts.active === account.id : false,
+			action: async () => {
+				if (opts.onChoose) {
+					opts.onChoose(account);
+				} else {
+					switchAccount(host, account.id);
+				}
+			},
+		};
+	}
+
+	const menuItems: MenuItem[] = [];
+
+	// TODO: $iのホストも比較したいけど通常null
+	const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user));
+
+	if (opts.withExtraOperation) {
+		menuItems.push({
+			type: 'link',
+			text: i18n.ts.profile,
+			to: `/@${$i.username}`,
+			avatar: $i,
+		}, {
+			type: 'divider',
+		});
+
+		if (opts.includeCurrentAccount) {
+			menuItems.push(createItem(host, $i));
+		}
+
+		menuItems.push(...accountItems);
+
+		menuItems.push({
+			type: 'parent',
+			icon: 'ti ti-plus',
+			text: i18n.ts.addAccount,
+			children: [{
+				text: i18n.ts.existingAccount,
+				action: () => {
+					getAccountWithSigninDialog().then(res => {
+						if (res != null) {
+							success();
+						}
+					});
+				},
+			}, {
+				text: i18n.ts.createAccount,
+				action: () => {
+					getAccountWithSignupDialog().then(res => {
+						if (res != null) {
+							switchAccount(host, res.id);
+						}
+					});
+				},
+			}],
+		}, {
+			type: 'link',
+			icon: 'ti ti-users',
+			text: i18n.ts.manageAccounts,
+			to: '/settings/accounts',
+		});
+	} else {
+		if (opts.includeCurrentAccount) {
+			menuItems.push(createItem(host, $i));
+		}
+
+		menuItems.push(...accountItems);
+	}
+
+	popupMenu(menuItems, ev.currentTarget ?? ev.target, {
+		align: 'left',
+	});
+}
+
+export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
+	return new Promise((resolve) => {
+		const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+			done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
+				const user = await fetchAccount(res.i, res.id, true);
+				await addAccount(host, user, res.i);
+				resolve({ id: res.id, token: res.i });
+			},
+			cancelled: () => {
+				resolve(null);
+			},
+			closed: () => {
+				dispose();
+			},
+		});
+	});
+}
+
+export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
+	return new Promise((resolve) => {
+		const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
+			done: async (res: Misskey.entities.SignupResponse) => {
+				const user = JSON.parse(JSON.stringify(res));
+				delete user.token;
+				await addAccount(host, user, res.token);
+				resolve({ id: res.id, token: res.token });
+			},
+			cancelled: () => {
+				resolve(null);
+			},
+			closed: () => {
+				dispose();
+			},
+		});
+	});
+}
diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts
index 3acc1127c9..e7e396023d 100644
--- a/packages/frontend/src/aiscript/api.ts
+++ b/packages/frontend/src/aiscript/api.ts
@@ -9,7 +9,7 @@ import { url, lang } from '@@/js/config.js';
 import { assertStringAndIsIn } from './common.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
 
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 122aa50ac0..73c4256c4b 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -15,7 +15,7 @@ import components from '@/components/index.js';
 import { applyTheme } from '@/theme.js';
 import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
 import { updateI18n, i18n } from '@/i18n.js';
-import { $i, refreshAccount, login } from '@/account.js';
+import { refreshCurrentAccount, login } from '@/accounts.js';
 import { store } from '@/store.js';
 import { fetchInstance, instance } from '@/instance.js';
 import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js';
@@ -29,6 +29,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
 import { setupRouter } from '@/router/main.js';
 import { createMainRouter } from '@/router/definition.js';
 import { prefer } from '@/preferences.js';
+import { $i } from '@/i.js';
 
 export async function common(createVue: () => App<Element>) {
 	console.info(`Misskey v${version}`);
@@ -38,11 +39,6 @@ export async function common(createVue: () => App<Element>) {
 
 		console.info(`vue ${vueVersion}`);
 
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
-		(window as any).$i = $i;
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
-		(window as any).$store = store;
-
 		window.addEventListener('error', event => {
 			console.error(event);
 			/*
@@ -244,7 +240,7 @@ export async function common(createVue: () => App<Element>) {
 			console.log('account cache found. refreshing...');
 		}
 
-		refreshAccount();
+		refreshCurrentAccount();
 	}
 	//#endregion
 
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index be72eeb9e1..64e3a236e8 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -16,7 +16,7 @@ import { i18n } from '@/i18n.js';
 import { alert, confirm, popup, post, toast } from '@/os.js';
 import { useStream } from '@/stream.js';
 import * as sound from '@/utility/sound.js';
-import { $i, signout, updateAccountPartial } from '@/account.js';
+import { $i } from '@/i.js';
 import { instance } from '@/instance.js';
 import { ColdDeviceStorage, store } from '@/store.js';
 import { reactionPicker } from '@/utility/reaction-picker.js';
@@ -32,6 +32,8 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { deckStore } from '@/ui/deck/deck-store.js';
 import { launchPlugins } from '@/plugin.js';
 import { unisonReload } from '@/utility/unison-reload.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
+import { signout } from '@/signout.js';
 
 export async function mainBoot() {
 	const { isClientUpdated, lastVersion } = await common(() => {
@@ -480,11 +482,11 @@ export async function mainBoot() {
 
 		// 自分の情報が更新されたとき
 		main.on('meUpdated', i => {
-			updateAccountPartial(i);
+			updateCurrentAccountPartial(i);
 		});
 
 		main.on('readAllNotifications', () => {
-			updateAccountPartial({
+			updateCurrentAccountPartial({
 				hasUnreadNotification: false,
 				unreadNotificationsCount: 0,
 			});
@@ -492,39 +494,39 @@ export async function mainBoot() {
 
 		main.on('unreadNotification', () => {
 			const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
-			updateAccountPartial({
+			updateCurrentAccountPartial({
 				hasUnreadNotification: true,
 				unreadNotificationsCount,
 			});
 		});
 
 		main.on('unreadMention', () => {
-			updateAccountPartial({ hasUnreadMentions: true });
+			updateCurrentAccountPartial({ hasUnreadMentions: true });
 		});
 
 		main.on('readAllUnreadMentions', () => {
-			updateAccountPartial({ hasUnreadMentions: false });
+			updateCurrentAccountPartial({ hasUnreadMentions: false });
 		});
 
 		main.on('unreadSpecifiedNote', () => {
-			updateAccountPartial({ hasUnreadSpecifiedNotes: true });
+			updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true });
 		});
 
 		main.on('readAllUnreadSpecifiedNotes', () => {
-			updateAccountPartial({ hasUnreadSpecifiedNotes: false });
+			updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false });
 		});
 
 		main.on('readAllAntennas', () => {
-			updateAccountPartial({ hasUnreadAntenna: false });
+			updateCurrentAccountPartial({ hasUnreadAntenna: false });
 		});
 
 		main.on('unreadAntenna', () => {
-			updateAccountPartial({ hasUnreadAntenna: true });
+			updateCurrentAccountPartial({ hasUnreadAntenna: true });
 			sound.playMisskeySfx('antenna');
 		});
 
 		main.on('readAllAnnouncements', () => {
-			updateAccountPartial({ hasUnreadAnnouncement: false });
+			updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
 		});
 
 		// 個人宛てお知らせが発行されたとき
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 41fd2564d8..582bb137bc 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -29,7 +29,8 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { $i, updateAccountPartial } from '@/account.js';
+import { $i } from '@/i.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
 
 const props = withDefaults(defineProps<{
 	announcement: Misskey.entities.Announcement;
@@ -51,7 +52,7 @@ async function ok() {
 
 	modal.value?.close();
 	misskeyApi('i/read-announcement', { announcementId: props.announcement.id });
-	updateAccountPartial({
+	updateCurrentAccountPartial({
 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
 	});
 }
diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue
index 090c31044e..00bf8e68d9 100644
--- a/packages/frontend/src/components/MkAuthConfirm.vue
+++ b/packages/frontend/src/components/MkAuthConfirm.vue
@@ -117,10 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script setup lang="ts">
 import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
-
 import MkButton from '@/components/MkButton.vue';
-
-import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
+import { $i } from '@/i.js';
+import { getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { getProxiedImageUrl } from '@/utility/media-proxy.js';
@@ -158,7 +157,7 @@ async function init() {
 
 	const accounts = await getAccounts();
 
-	const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
+	const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id));
 
 	if (accountIdsToFetch.length > 0) {
 		const usersRes = await misskeyApi('users/show', {
@@ -170,7 +169,7 @@ async function init() {
 
 			users.value.set(user.id, {
 				...user,
-				token: accounts.find(a => a.id === user.id)!.token,
+				token: accounts.find(a => a.user.id === user.id)!.token,
 			});
 		}
 	}
diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue
index 5b09ec90dd..2154c08ab3 100644
--- a/packages/frontend/src/components/MkClipPreview.vue
+++ b/packages/frontend/src/components/MkClipPreview.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import * as Misskey from 'misskey-js';
 import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import number from '@/filters/number.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 3c41d597de..4a89fb30ca 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -38,7 +38,7 @@ import tinycolor from 'tinycolor2';
 import { apiUrl } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import * as os from '@/os.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index 733d50728e..f02a767186 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -44,7 +44,7 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import bytes from '@/filters/bytes.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
 import { deviceKind } from '@/utility/device-kind.js';
 import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 384682277e..c0883b1342 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -136,7 +136,7 @@ import { deviceKind } from '@/utility/device-kind.js';
 import { i18n } from '@/i18n.js';
 import { store } from '@/store.js';
 import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index a063854520..b62494fa20 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -44,7 +44,7 @@ import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import { pleaseLogin } from '@/utility/please-login.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 70777bb89a..3113c6fad6 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -89,7 +89,7 @@ import { Chart } from 'chart.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import * as os from '@/os.js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { instance } from '@/instance.js';
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 33d4d269e7..096c51bbd6 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -98,7 +98,7 @@ import * as os from '@/os.js';
 import bytes from '@/filters/bytes.js';
 import { hms } from '@/filters/hms.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 6029b1e0b6..20ac1a917e 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -60,7 +60,7 @@ import bytes from '@/filters/bytes.js';
 import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 74c1aefc3a..403ec61736 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -121,7 +121,7 @@ import * as os from '@/os.js';
 import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
 import hasAudio from '@/utility/media-has-audio.js';
 import MkMediaRange from '@/components/MkMediaRange.vue';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index 419407955f..f2cf33eb65 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -18,7 +18,7 @@ import { toUnicode } from 'punycode.js';
 import { computed } from 'vue';
 import { host as localHost } from '@@/js/config.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index aa352d5163..a22ad346bf 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -208,7 +208,7 @@ import * as sound from '@/utility/sound.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { reactionPicker } from '@/utility/reaction-picker.js';
 import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
 import { useNoteCapture } from '@/use/use-note-capture.js';
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index b3f99b702a..dd8d3567b2 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -238,7 +238,7 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import * as sound from '@/utility/sound.js';
 import { reactionPicker } from '@/utility/reaction-picker.js';
 import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
 import { useNoteCapture } from '@/use/use-note-capture.js';
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index bb7347cd26..4fd1c210cb 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -48,7 +48,7 @@ import MkCwButton from '@/components/MkCwButton.vue';
 import { notePage } from '@/filters/note.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { userPage } from '@/filters/user.js';
 import { checkWordMute } from '@/utility/check-word-mute.js';
 
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 5d096cf92d..b2380a5e0e 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -169,7 +169,7 @@ import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { infoImageUrl } from '@/instance.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index 4d1787d420..24750012d0 100644
--- a/packages/frontend/src/components/MkPasswordDialog.vue
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 5e379d08b7..c6958eea77 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -127,7 +127,8 @@ import { store } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js';
+import { signinRequired, notesCount, incNotesCount } from '@/i.js';
+import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
 import { uploadFile } from '@/utility/upload.js';
 import { deepClone } from '@/utility/clone.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
@@ -837,7 +838,7 @@ async function post(ev?: MouseEvent) {
 
 	if (postAccount.value) {
 		const storedAccounts = await getAccounts();
-		token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
+		token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token;
 	}
 
 	posting.value = true;
diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue
index 6efd99d14b..d8dfbd1655 100644
--- a/packages/frontend/src/components/MkPreview.vue
+++ b/packages/frontend/src/components/MkPreview.vue
@@ -43,7 +43,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkRadio from '@/components/MkRadio.vue';
 import * as os from '@/os.js';
 import * as config from '@@/js/config.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const text = ref('');
 const flag = ref(true);
diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
index 780f8bc6d0..9c37eb5e72 100644
--- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue
+++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
@@ -42,12 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script setup lang="ts">
 import { ref } from 'vue';
-import { $i, getAccounts } from '@/account.js';
+import { $i } from '@/i.js';
 import MkButton from '@/components/MkButton.vue';
 import { instance } from '@/instance.js';
 import { apiWithDialog, promiseDialog } from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
+import { getAccounts } from '@/accounts.js';
 
 defineProps<{
 	primary?: boolean;
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index d079e68cde..12a066c710 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -27,7 +27,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
 import { useTooltip } from '@/use/use-tooltip.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
 import { claimAchievement } from '@/utility/achievements.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 2dcc87f425..b0fbe3c490 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -67,20 +67,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
-
 import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
 import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
+import type { PwResponse } from '@/components/MkSignin.password.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
-import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 
 import XInput from '@/components/MkSignin.input.vue';
 import XPassword from '@/components/MkSignin.password.vue';
-import type { PwResponse } from '@/components/MkSignin.password.vue';
 import XTotp from '@/components/MkSignin.totp.vue';
 import XPasskey from '@/components/MkSignin.passkey.vue';
+import { login } from '@/accounts.js';
 
 const emit = defineEmits<{
 	(ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void;
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 151cd60fb2..78b6722c1e 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -85,13 +85,13 @@ import * as Misskey from 'misskey-js';
 import * as config from '@@/js/config.js';
 import MkButton from './MkButton.vue';
 import MkInput from './MkInput.vue';
-import MkCaptcha from '@/components/MkCaptcha.vue';
 import type { Captcha } from '@/components/MkCaptcha.vue';
+import MkCaptcha from '@/components/MkCaptcha.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { login } from '@/account.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
+import { login } from '@/accounts.js';
 
 const props = withDefaults(defineProps<{
 	autoSet?: boolean;
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 62ae9a048a..e8b740eae2 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -25,7 +25,7 @@ import MkNotes from '@/components/MkNotes.vue';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { useStream } from '@/stream.js';
 import * as sound from '@/utility/sound.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { instance } from '@/instance.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
index 31ecb15ab8..bed15031cb 100644
--- a/packages/frontend/src/components/MkTokenGenerateWindow.vue
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -55,7 +55,7 @@ import MkButton from './MkButton.vue';
 import MkInfo from './MkInfo.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
-import { iAmAdmin } from '@/account.js';
+import { iAmAdmin } from '@/i.js';
 
 const props = withDefaults(defineProps<{
 	title?: string | null;
diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue
index b26a01737e..59e1b096ae 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Note.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue
@@ -27,7 +27,7 @@ import * as Misskey from 'misskey-js';
 import { ref, reactive } from 'vue';
 import { i18n } from '@/i18n.js';
 import { globalEvents } from '@/events.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkNote from '@/components/MkNote.vue';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
index f7b60fbc45..8ae6c1ceaa 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
@@ -31,7 +31,7 @@ import MkPostForm from '@/components/MkPostForm.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkNote from '@/components/MkNote.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const emit = defineEmits<{
 	(ev: 'succeeded'): void;
diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index eb189b446b..cff531b2ca 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -39,7 +39,7 @@ import MkFollowButton from '@/components/MkFollowButton.vue';
 import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 8a89b253e3..7e8b1200d5 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -65,7 +65,7 @@ import { getUserMenu } from '@/utility/get-user-menu.js';
 import number from '@/filters/number.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
 
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index e5c6df267b..6bf3eb44dc 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -70,7 +70,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { instance } from '@/instance.js';
 
 const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
index 14acfd3f89..7a5e5772a4 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -39,7 +39,7 @@ import FormSlot from '@/components/form/slot.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { chooseFileFromPc } from '@/utility/select-file.js';
 import * as os from '@/os.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index c196519c15..b55069ca25 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -47,7 +47,7 @@ import { instance } from '@/instance.js';
 import MkButton from '@/components/MkButton.vue';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 type Ad = (typeof instance)['ads'][number];
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index 9981092dff..20a07e9c28 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -35,7 +35,7 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as sound from '@/utility/sound.js';
 import { i18n } from '@/i18n.js';
 import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 728e37cf51..69bbd88cb6 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -50,7 +50,8 @@ import type { PageHeaderItem } from '@/types/page-header.js';
 import type { PageMetadata } from '@/page.js';
 import { globalEvents } from '@/events.js';
 import { injectReactiveMetadata } from '@/page.js';
-import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
 
 const props = withDefaults(defineProps<{
 	overridePageMetadata?: PageMetadata;
diff --git a/packages/frontend/src/i.ts b/packages/frontend/src/i.ts
new file mode 100644
index 0000000000..aa84c6aa61
--- /dev/null
+++ b/packages/frontend/src/i.ts
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { reactive } from 'vue';
+import * as Misskey from 'misskey-js';
+import { miLocalStorage } from '@/local-storage.js';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
+
+const accountData = miLocalStorage.getItem('account');
+
+// TODO: 外部からはreadonlyに
+export const $i = accountData ? reactive(JSON.parse(accountData) as AccountWithToken) : null;
+
+export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true);
+export const iAmAdmin = $i != null && $i.isAdmin;
+
+export function signinRequired() {
+	if ($i == null) throw new Error('signin required');
+	return $i;
+}
+
+export let notesCount = $i == null ? 0 : $i.notesCount;
+export function incNotesCount() {
+	notesCount++;
+}
+
+if (_DEV_) {
+	(window as any).$i = $i;
+}
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 3977edb91b..099339fbee 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -9,7 +9,6 @@ export type Keys = (
 	'instance' |
 	'instanceCachedAt' |
 	'account' |
-	'accounts' |
 	'latestDonationInfoShownAt' |
 	'neverShowDonationInfo' |
 	'neverShowLocalOnlyInfo' |
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 837f333c9a..c0a6a370fc 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -5,7 +5,7 @@
 
 import { computed, reactive } from 'vue';
 import { clearCache } from './utility/clear-cache.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
 import { lookup } from '@/utility/lookup.js';
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 5395429d7f..36dac2954d 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -146,7 +146,7 @@ import { instance } from '@/instance.js';
 import * as os from '@/os.js';
 import { definePage } from '@/page.js';
 import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 const patronsWithIcon = [{
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue
index d7d526f3ba..b166dfd940 100644
--- a/packages/frontend/src/pages/about.emojis.vue
+++ b/packages/frontend/src/pages/about.emojis.vue
@@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const customEmojiTags = getCustomEmojiTags();
 const q = ref('');
diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue
index 53ce75f9bf..ca2443cc5b 100644
--- a/packages/frontend/src/pages/achievements.vue
+++ b/packages/frontend/src/pages/achievements.vue
@@ -17,7 +17,7 @@ import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
 import MkAchievements from '@/components/MkAchievements.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { claimAchievement } from '@/utility/achievements.js';
 
 let timer: number | null;
diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue
index 0af28e94fa..9274b90892 100644
--- a/packages/frontend/src/pages/admin-file.vue
+++ b/packages/frontend/src/pages/admin-file.vue
@@ -86,7 +86,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { iAmAdmin, iAmModerator } from '@/account.js';
+import { iAmAdmin, iAmModerator } from '@/i.js';
 
 const tab = ref('overview');
 const file = ref<Misskey.entities.DriveFile | null>(null);
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 2efae999ce..ff1d5e64d3 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -233,7 +233,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { acct } from '@/filters/user.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { iAmAdmin, $i, iAmModerator } from '@/account.js';
+import { iAmAdmin, $i, iAmModerator } from '@/i.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 
diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue
index 977bbe0b47..6562610b12 100644
--- a/packages/frontend/src/pages/announcement.vue
+++ b/packages/frontend/src/pages/announcement.vue
@@ -55,8 +55,9 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { $i, updateAccountPartial } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
 
 const props = defineProps<{
 	announcementId: string;
@@ -90,7 +91,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> {
 	target.isRead = true;
 	await misskeyApi('i/read-announcement', { announcementId: target.id });
 	if ($i) {
-		updateAccountPartial({
+		updateCurrentAccountPartial({
 			unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
 		});
 	}
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index 13f28d9b35..2387ac728b 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -56,7 +56,8 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { $i, updateAccountPartial } from '@/account.js';
+import { $i } from '@/i.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
 
 const paginationCurrent = {
 	endpoint: 'announcements' as const,
@@ -94,7 +95,7 @@ async function read(target) {
 		return a;
 	});
 	misskeyApi('i/read-announcement', { announcementId: target.id });
-	updateAccountPartial({
+	updateCurrentAccountPartial({
 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
 	});
 }
diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue
index 8b0fde4a25..e4699379f0 100644
--- a/packages/frontend/src/pages/auth.vue
+++ b/packages/frontend/src/pages/auth.vue
@@ -47,9 +47,10 @@ import * as Misskey from 'misskey-js';
 import XForm from './auth.form.vue';
 import MkSignin from '@/components/MkSignin.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i, login } from '@/account.js';
+import { $i } from '@/i.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
+import { login } from '@/accounts.js';
 
 const props = defineProps<{
 	token: string;
diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
index a3c5a36614..884429dfeb 100644
--- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
+++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
@@ -73,7 +73,7 @@ import { i18n } from '@/i18n.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue
index eb1015b19e..b84b9efc1a 100644
--- a/packages/frontend/src/pages/avatar-decorations.vue
+++ b/packages/frontend/src/pages/avatar-decorations.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, computed, defineAsyncComponent } from 'vue';
 import * as Misskey from 'misskey-js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index a774aa6e44..c5951f0e13 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -82,7 +82,7 @@ import MkTimeline from '@/components/MkTimeline.vue';
 import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { deviceKind } from '@/utility/device-kind.js';
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index 9765ebf216..590a506a55 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -36,7 +36,7 @@ import * as Misskey from 'misskey-js';
 import { url } from '@@/js/config.js';
 import type { MenuItem } from '@/types/menu.js';
 import MkNotes from '@/components/MkNotes.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index f760aca8ae..364006e9ad 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -208,7 +208,7 @@ import { claimAchievement } from '@/utility/achievements.js';
 import { store } from '@/store.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import * as sound from '@/utility/sound.js';
 import MkRange from '@/components/MkRange.vue';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue
index 35a240b9ba..bedb0b64f9 100644
--- a/packages/frontend/src/pages/emojis.emoji.vue
+++ b/packages/frontend/src/pages/emojis.emoji.vue
@@ -22,7 +22,7 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const props = defineProps<{
   emoji: Misskey.entities.EmojiSimple;
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 6bce6689d4..08ac913958 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -80,7 +80,7 @@ import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkCode from '@/components/MkCode.vue';
 import { prefer } from '@/preferences.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { isSupportShare } from '@/utility/navigator.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { pleaseLogin } from '@/utility/please-login.js';
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index bd48b882d2..7e496f522d 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -54,7 +54,7 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { infoImageUrl } from '@/instance.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index 56ddb820cf..eb01aadbcc 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -77,7 +77,7 @@ import MkFollowButton from '@/components/MkFollowButton.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { prefer } from '@/preferences.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { isSupportShare } from '@/utility/navigator.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index eddeb4aba9..c4aed8d6df 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -148,7 +148,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import number from '@/filters/number.js';
-import { iAmModerator, iAmAdmin } from '@/account.js';
+import { iAmModerator, iAmAdmin } from '@/i.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index 8369927d85..352e1d9386 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -45,7 +45,7 @@ import type { Paging } from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
 import { serverErrorImageUrl, instance } from '@/instance.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
 const currentInviteLimit = ref<null | number>(null);
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
index 0bc9b3f3c2..cc701cb16b 100644
--- a/packages/frontend/src/pages/my-lists/index.vue
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -37,7 +37,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { userListsCache } from '@/cache.js';
 import { infoImageUrl } from '@/instance.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index fdee890cfd..6481c0da0c 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -66,7 +66,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInput from '@/components/MkInput.vue';
 import { userListsCache } from '@/cache.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import MkPagination from '@/components/MkPagination.vue';
 import { mainRouter } from '@/router/main.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index 6f53cba806..fb83993fee 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -65,7 +65,7 @@ import { prefer } from '@/preferences.js';
 import { pleaseLogin } from '@/utility/please-login.js';
 import { getAppearNote } from '@/utility/get-appear-note.js';
 import { serverContext, assertServerContext } from '@/server-context.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 // contextは非ログイン状態の情報しかないためログイン時は利用できない
 const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index ed701ed3c0..e2f6084252 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -75,7 +75,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { selectFile } from '@/utility/select-file.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { mainRouter } from '@/router/main.js';
 import { getPageBlockList } from '@/pages/page-editor/common.js';
 
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 1c288442b5..00c664d2a0 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -115,7 +115,7 @@ import MkPagePreview from '@/components/MkPagePreview.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { deepClone } from '@/utility/clone.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { isSupportShare } from '@/utility/navigator.js';
 import { instance } from '@/instance.js';
 import { getStaticImageUrl } from '@/utility/media-proxy.js';
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 71dd220cfe..ef9cc242c6 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -150,7 +150,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import { deepClone } from '@/utility/clone.js';
 import { useInterval } from '@@/js/use-interval.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { url } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue
index 03b75f89ae..2715b70b95 100644
--- a/packages/frontend/src/pages/reversi/game.setting.vue
+++ b/packages/frontend/src/pages/reversi/game.setting.vue
@@ -114,7 +114,7 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as Reversi from 'misskey-reversi';
 import { i18n } from '@/i18n.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { deepClone } from '@/utility/clone.js';
 import MkButton from '@/components/MkButton.vue';
 import MkRadios from '@/components/MkRadios.vue';
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index 053ec2aa08..662df00d9b 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -17,7 +17,7 @@ import GameBoard from './game.board.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { useStream } from '@/stream.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { useRouter } from '@/router/supplier.js';
 import * as os from '@/os.js';
 import { url } from '@@/js/config.js';
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
index ff2e7e922f..d66ff8db05 100644
--- a/packages/frontend/src/pages/reversi/index.vue
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -113,7 +113,7 @@ import { useStream } from '@/stream.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkPagination from '@/components/MkPagination.vue';
 import { useRouter } from '@/router/supplier.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index ed5cd23b23..b0d3b5bbd2 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -66,7 +66,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
 import * as os from '@/os.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { registerAsUiLib } from '@/aiscript/ui.js';
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index bfe56e793b..4801e9bc27 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref, shallowRef, toRef } from 'vue';
 import type * as Misskey from 'misskey-js';
 import type { Paging } from '@/components/MkPagination.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { host as localHost } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index 9093ffd7a9..41a2535813 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -117,7 +117,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkLink from '@/components/MkLink.vue';
 import { confetti } from '@/utility/confetti.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 806599e801..20d1b0fe0f 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -92,8 +92,9 @@ import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkLink from '@/components/MkLink.vue';
 import * as os from '@/os.js';
-import { signinRequired, updateAccountPartial } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { i18n } from '@/i18n.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
 
 const $i = signinRequired();
 
@@ -131,7 +132,7 @@ async function unregisterTOTP(): Promise<void> {
 		password: auth.result.password,
 		token: auth.result.token,
 	}).then(res => {
-		updateAccountPartial({
+		updateCurrentAccountPartial({
 			twoFactorEnabled: false,
 		});
 	}).catch(error => {
diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue
index ed5fe48821..14bea577a3 100644
--- a/packages/frontend/src/pages/settings/account-data.vue
+++ b/packages/frontend/src/pages/settings/account-data.vue
@@ -167,7 +167,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { selectFile } from '@/utility/select-file.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
index 2cf65be2d0..749ae5147f 100644
--- a/packages/frontend/src/pages/settings/accounts.vue
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -4,80 +4,51 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="">
-	<FormSuspense :p="init">
-		<div class="_gaps">
-			<div class="_buttons">
-				<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton>
-				<MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton>
-			</div>
-
-			<template v-for="[id, user] in accounts">
-				<MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
-				<button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)">
-					<div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div>
-					<div>
-						<div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div>
-						<div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div>
-					</div>
-				</button>
-			</template>
+<SearchMarker path="/settings/accounts" :label="i18n.ts.accounts" :keywords="['accounts']" icon="ti ti-users">
+	<div class="_gaps">
+		<div class="_buttons">
+			<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton>
+			<!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>-->
 		</div>
-	</FormSuspense>
-</div>
+
+		<MkUserCardMini v-for="x in accounts" :key="x[0] + x[1].id" :user="x[1]" :class="$style.user" @click.prevent="menu(x[0], x[1], $event)"/>
+	</div>
+</SearchMarker>
 </template>
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
-import FormSuspense from '@/components/form/suspense.vue';
+import type { MenuItem } from '@/types/menu.js';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
+import { $i } from '@/i.js';
+import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
-import type { MenuItem } from '@/types/menu.js';
-
-const storedAccounts = ref<{ id: string, token: string }[] | null>(null);
-const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>());
+import { prefer } from '@/preferences.js';
 
-const init = async () => {
-	getAccounts().then(accounts => {
-		storedAccounts.value = accounts.filter(x => x.id !== $i!.id);
+const accounts = prefer.r.accounts;
 
-		return misskeyApi('users/show', {
-			userIds: storedAccounts.value.map(x => x.id),
-		});
-	}).then(response => {
-		if (storedAccounts.value == null) return;
-		accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null]));
-	});
-};
+function refreshAllAccounts() {
+	// TODO
+}
 
-function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) {
+function menu(host: string, account: Misskey.entities.UserDetailed, ev: MouseEvent) {
 	let menu: MenuItem[];
 
-	if (typeof account === 'string') {
-		menu = [{
-			text: i18n.ts.logout,
-			icon: 'ti ti-trash',
-			danger: true,
-			action: () => removeAccount(account),
-		}];
-	} else {
-		menu = [{
-			text: i18n.ts.switch,
-			icon: 'ti ti-switch-horizontal',
-			action: () => switchAccount(account.id),
-		}, {
-			text: i18n.ts.logout,
-			icon: 'ti ti-trash',
-			danger: true,
-			action: () => removeAccount(account.id),
-		}];
-	}
+	menu = [{
+		text: i18n.ts.switch,
+		icon: 'ti ti-switch-horizontal',
+		action: () => switchAccount(host, account.id),
+	}, {
+		text: i18n.ts.logout,
+		icon: 'ti ti-trash',
+		danger: true,
+		action: () => removeAccount(host, account.id),
+	}];
 
 	os.popupMenu(menu, ev.currentTarget ?? ev.target);
 }
@@ -92,16 +63,10 @@ function addAccount(ev: MouseEvent) {
 	}], ev.currentTarget ?? ev.target);
 }
 
-async function removeAccount(id: string) {
-	await _removeAccount(id);
-	accounts.value.delete(id);
-}
-
 function addExistingAccount() {
 	getAccountWithSigninDialog().then((res) => {
 		if (res != null) {
 			os.success();
-			init();
 		}
 	});
 }
@@ -109,21 +74,11 @@ function addExistingAccount() {
 function createAccount() {
 	getAccountWithSignupDialog().then((res) => {
 		if (res != null) {
-			switchAccountWithToken(res.token);
+			login(res.token);
 		}
 	});
 }
 
-async function switchAccount(id: string) {
-	const fetchedAccounts = await getAccounts();
-	const token = fetchedAccounts.find(x => x.id === id)!.token;
-	switchAccountWithToken(token);
-}
-
-function switchAccountWithToken(token: string) {
-	login(token);
-}
-
 const headerActions = computed(() => []);
 
 const headerTabs = computed(() => []);
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
index 3c9914b4e2..6b3bb1b513 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed } from 'vue';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
index 40542ad5b2..e4803eda2e 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
@@ -51,7 +51,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import { i18n } from '@/i18n.js';
 import MkRange from '@/components/MkRange.vue';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue
index ba25eee175..91549e5240 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.vue
@@ -54,7 +54,7 @@ import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { definePage } from '@/page.js';
 
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 34941d5af0..0b25ee5e37 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -104,7 +104,7 @@ import bytes from '@/filters/bytes.js';
 import MkChart from '@/components/MkChart.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { prefer } from '@/preferences.js';
 import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
 import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index 0cbda44882..10e2a000d4 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -67,7 +67,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkDisableSection from '@/components/MkDisableSection.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { instance } from '@/instance.js';
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 0579b6d14a..ba5957f06a 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -38,7 +38,7 @@ import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import { i18n } from '@/i18n.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkSuperMenu from '@/components/MkSuperMenu.vue';
-import { signout, $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { clearCache } from '@/utility/clear-cache.js';
 import { instance } from '@/instance.js';
 import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
@@ -47,6 +47,7 @@ import { useRouter } from '@/router/supplier.js';
 import { searchIndexes } from '@/utility/autogen/settings-search-index.js';
 import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js';
 import { store } from '@/store.js';
+import { signout } from '@/signout.js';
 
 const SETTING_INDEX = searchIndexes; // TODO: lazy load
 
diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue
index 60386da545..260e390b51 100644
--- a/packages/frontend/src/pages/settings/migration.vue
+++ b/packages/frontend/src/pages/settings/migration.vue
@@ -68,7 +68,7 @@ import MkUserInfo from '@/components/MkUserInfo.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { unisonReload } from '@/utility/unison-reload.js';
 
 const $i = signinRequired();
diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
index 52e1937663..1c942e715a 100644
--- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
+++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue
@@ -19,7 +19,7 @@ import { ref, watch } from 'vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkButton from '@/components/MkButton.vue';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index a5ab7caf99..ce762af071 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -188,7 +188,7 @@ import { definePage } from '@/page.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import * as os from '@/os.js';
 import { instance, infoImageUrl } from '@/instance.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 49910cdf4a..785fcdfbce 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -75,7 +75,7 @@ import FormSection from '@/components/form/section.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import * as os from '@/os.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 62b0f5c941..27fb743cb2 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -127,13 +127,14 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { signout, signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { reloadAsk } from '@/utility/reload-ask.js';
 import FormSection from '@/components/form/section.vue';
 import { prefer } from '@/preferences.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
+import { signout } from '@/signout.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index edc750c295..b73f781b9c 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -185,7 +185,7 @@ import MkFolder from '@/components/MkFolder.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { definePage } from '@/page.js';
 import FormSlot from '@/components/form/slot.vue';
 import { formatDateTimeString } from '@/utility/format-time-string.js';
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 1d85ba7834..b12ba9fe93 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -164,7 +164,7 @@ import FormSlot from '@/components/form/slot.vue';
 import { selectFile } from '@/utility/select-file.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired } from '@/i.js';
 import { langmap } from '@/utility/langmap.js';
 import { definePage } from '@/page.js';
 import { claimAchievement } from '@/utility/achievements.js';
diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue
index c3f1b4b18e..e37f05f1be 100644
--- a/packages/frontend/src/pages/signup-complete.vue
+++ b/packages/frontend/src/pages/signup-complete.vue
@@ -28,10 +28,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkAnimBg from '@/components/MkAnimBg.vue';
-import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
+import { login } from '@/accounts.js';
 
 const submitting = ref(false);
 
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
index 1af69d82db..868c64d06d 100644
--- a/packages/frontend/src/pages/tag.vue
+++ b/packages/frontend/src/pages/tag.vue
@@ -25,7 +25,7 @@ import MkNotes from '@/components/MkNotes.vue';
 import MkButton from '@/components/MkButton.vue';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { genEmbedCode } from '@/utility/get-embed-code.js';
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index 5ee42cc1ec..45d8c96ab7 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -86,7 +86,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkFolder from '@/components/MkFolder.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { applyTheme } from '@/theme.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 0ac6979b05..68f4b7a26d 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -48,7 +48,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { definePage } from '@/page.js';
 import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
 import { deviceKind } from '@/utility/device-kind.js';
diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue
index b78ac2dc17..8f13e959e1 100644
--- a/packages/frontend/src/pages/user/achievements.vue
+++ b/packages/frontend/src/pages/user/achievements.vue
@@ -14,7 +14,7 @@ import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkAchievements from '@/components/MkAchievements.vue';
 import { claimAchievement } from '@/utility/achievements.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const props = defineProps<{
 	user: Misskey.entities.User;
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 6450f1e077..149481f99b 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -176,7 +176,7 @@ import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { dateString } from '@/filters/date.js';
 import { confetti } from '@/utility/confetti.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index b5127de390..16413a55cc 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -38,7 +38,7 @@ import { acct as getAcct } from '@/filters/user.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { serverContext, assertServerContext } from '@/server-context.js';
 
diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue
index 939ca934e8..d9e3ca9966 100644
--- a/packages/frontend/src/pages/welcome.setup.vue
+++ b/packages/frontend/src/pages/welcome.setup.vue
@@ -45,9 +45,9 @@ import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 import MkAnimBg from '@/components/MkAnimBg.vue';
+import { login } from '@/accounts.js';
 
 const username = ref('');
 const password = ref('');
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts
index f55b1e93cf..3ebf2ab4e4 100644
--- a/packages/frontend/src/pizzax.ts
+++ b/packages/frontend/src/pizzax.ts
@@ -8,7 +8,7 @@
 import { onUnmounted, ref, watch } from 'vue';
 import { BroadcastChannel } from 'broadcast-channel';
 import type { Ref } from 'vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { get, set } from '@/utility/idb-proxy.js';
 import { store } from '@/store.js';
diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts
index 8785acd7d1..f9e6ab2a75 100644
--- a/packages/frontend/src/preferences.ts
+++ b/packages/frontend/src/preferences.ts
@@ -9,7 +9,7 @@ import { cloudBackup } from '@/preferences/utility.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
 import { store } from '@/store.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 
 const TAB_ID = uuid();
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index eb3d6eeac4..e460359acd 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -32,6 +32,10 @@ export type SoundStore = {
 // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
 
 export const PREF_DEF = {
+	accounts: {
+		default: [] as [host: string, user: Misskey.entities.User][],
+	},
+
 	pinnedUserLists: {
 		accountDependent: true,
 		default: [] as Misskey.entities.UserList[],
diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts
index b053cadacb..fad0226b6e 100644
--- a/packages/frontend/src/preferences/manager.ts
+++ b/packages/frontend/src/preferences/manager.ts
@@ -9,7 +9,7 @@ import { host, version } from '@@/js/config.js';
 import { PREF_DEF } from './def.js';
 import type { Ref, WritableComputedRef } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index c37dbcf96b..bf3dfa157f 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -12,7 +12,7 @@ import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { unisonReload } from '@/utility/unison-reload.js';
 
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 73920766d7..9a81032cc8 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -7,7 +7,7 @@ import { defineAsyncComponent } from 'vue';
 import type { AsyncComponentLoader } from 'vue';
 import type { IRouter, RouteDef } from '@/nirax.js';
 import { Router } from '@/nirax.js';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import MkLoading from '@/pages/_loading_.vue';
 import MkError from '@/pages/_error_.vue';
 
diff --git a/packages/frontend/src/signout.ts b/packages/frontend/src/signout.ts
new file mode 100644
index 0000000000..8e90552546
--- /dev/null
+++ b/packages/frontend/src/signout.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent, ref } from 'vue';
+import { apiUrl, host } from '@@/js/config.js';
+import { defaultMemoryStorage } from '@/memory-storage';
+import { i18n } from '@/i18n.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { waiting, popup, popupMenu, success, alert } from '@/os.js';
+import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import { $i } from '@/i.js';
+
+export async function signout() {
+	if (!$i) return;
+
+	defaultMemoryStorage.clear();
+
+	waiting();
+	miLocalStorage.removeItem('account');
+
+	// TODO: preferencesも削除
+
+	//#region Remove service worker registration
+	try {
+		if (navigator.serviceWorker.controller) {
+			const registration = await navigator.serviceWorker.ready;
+			const push = await registration.pushManager.getSubscription();
+			if (push) {
+				await window.fetch(`${apiUrl}/sw/unregister`, {
+					method: 'POST',
+					body: JSON.stringify({
+						i: $i.token,
+						endpoint: push.endpoint,
+					}),
+					headers: {
+						'Content-Type': 'application/json',
+					},
+				});
+			}
+		}
+
+		await navigator.serviceWorker.getRegistrations()
+			.then(registrations => {
+				return Promise.all(registrations.map(registration => registration.unregister()));
+			});
+	} catch (err) {}
+	//#endregion
+
+	unisonReload('/');
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 9a61e63d0e..ffbd8d10ef 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -104,6 +104,10 @@ export const store = markRaw(new Storage('base', {
 		where: 'deviceAccount',
 		default: {} as Record<string, string>, // plugin id, token
 	},
+	accountTokens: {
+		where: 'device',
+		default: {} as Record<string, string>, // host/userId, token
+	},
 
 	enablePreferencesAutoCloudBackup: {
 		where: 'device',
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index e194e96a7f..c97d7d4071 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -5,7 +5,7 @@
 
 import * as Misskey from 'misskey-js';
 import { markRaw } from 'vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { wsOrigin } from '@@/js/config.js';
 // TODO: No WebsocketモードでStreamMockが使えそう
 //import { StreamMock } from '@/utility/stream-mock.js';
diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts
index 5d09ec27f9..2ae5d8730e 100644
--- a/packages/frontend/src/theme-store.ts
+++ b/packages/frontend/src/theme-store.ts
@@ -5,7 +5,7 @@
 
 import type { Theme } from '@/theme.js';
 import { getBuiltinThemes } from '@/theme.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { prefer } from '@/preferences.js';
 
 export function getThemes(): Theme[] {
diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts
index 94eda3545e..a39ccd481d 100644
--- a/packages/frontend/src/timelines.ts
+++ b/packages/frontend/src/timelines.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { instance } from '@/instance.js';
 
 export const basicTimelineTypes = [
diff --git a/packages/frontend/src/ui/_common_/PreferenceRestore.vue b/packages/frontend/src/ui/_common_/PreferenceRestore.vue
index c70b82cd0e..5fd9f5e44b 100644
--- a/packages/frontend/src/ui/_common_/PreferenceRestore.vue
+++ b/packages/frontend/src/ui/_common_/PreferenceRestore.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { hideRestoreBackupSuggestion, restoreFromCloudBackup } from '@/preferences/utility.js';
 
diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue
index d153dc8726..f9af8e1ee7 100644
--- a/packages/frontend/src/ui/_common_/announcements.vue
+++ b/packages/frontend/src/ui/_common_/announcements.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts
index 8e5ba8927a..819e1fa42f 100644
--- a/packages/frontend/src/ui/_common_/common.ts
+++ b/packages/frontend/src/ui/_common_/common.ts
@@ -9,7 +9,7 @@ import * as os from '@/os.js';
 import { instance } from '@/instance.js';
 import { host } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 function toolsMenuItems(): MenuItem[] {
 	return [{
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index e218cd8c62..a39a4ee86b 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -53,7 +53,7 @@ import { popups } from '@/os.js';
 import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
 import { uploads } from '@/utility/upload.js';
 import * as sound from '@/utility/sound.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 698e9d8d47..2fbc9ab4b3 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -53,10 +53,11 @@ import { computed, defineAsyncComponent, toRef } from 'vue';
 import { openInstanceMenu } from './common.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
 import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
 
 const menu = toRef(prefer.s, 'menu');
 const otherMenuItemIndicated = computed(() => {
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 234972e76d..1810ec1743 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -93,13 +93,14 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
 import { openInstanceMenu } from './common.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
 import { useRouter } from '@/router/supplier.js';
 import { prefer } from '@/preferences.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
 
 const router = useRouter();
 
diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts
index df392c6532..ae61e497b5 100644
--- a/packages/frontend/src/ui/_common_/sw-inject.ts
+++ b/packages/frontend/src/ui/_common_/sw-inject.ts
@@ -5,10 +5,11 @@
 
 import { post } from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i, login } from '@/account.js';
+import { $i } from '@/i.js';
 import { getAccountFromId } from '@/utility/get-account-from-id.js';
 import { deepClone } from '@/utility/clone.js';
 import { mainRouter } from '@/router/main.js';
+import { login } from '@/accounts.js';
 
 export function swInject() {
 	navigator.serviceWorker.addEventListener('message', async ev => {
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index 39b40754ff..1e8a342977 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -51,12 +51,12 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
 import { openInstanceMenu } from './_common_/common.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
-import { store } from '@/store.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
 
 const WINDOW_THRESHOLD = 1400;
 
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 259aad7401..096ea0d4cf 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -54,7 +54,6 @@ import { openInstanceMenu } from './_common_/common.js';
 // import { host } from '@@/js/config.js';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
 import MkButton from '@/components/MkButton.vue';
 // import { StickySidebar } from '@/utility/sticky-sidebar.js';
 // import { mainRouter } from '@/router.js';
@@ -63,6 +62,8 @@ import { store } from '@/store.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
 
 const WINDOW_THRESHOLD = 1400;
 
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 337b0dac94..a5db4031e2 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -100,7 +100,7 @@ import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { deviceKind } from '@/utility/device-kind.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index cb93b20c24..29248b2720 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -108,7 +108,7 @@ import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
 import * as os from '@/os.js';
 import { navbarItemDef } from '@/navbar.js';
 import { i18n } from '@/i18n.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
 import { deviceKind } from '@/utility/device-kind.js';
 import { miLocalStorage } from '@/local-storage.js';
diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts
index 0bc10e90e4..0de2dbb3c5 100644
--- a/packages/frontend/src/use/use-note-capture.ts
+++ b/packages/frontend/src/use/use-note-capture.ts
@@ -7,7 +7,7 @@ import { onUnmounted } from 'vue';
 import type { Ref, ShallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useStream } from '@/stream.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 export function useNoteCapture(props: {
 	rootEl: ShallowRef<HTMLElement | undefined>;
diff --git a/packages/frontend/src/utility/achievements.ts b/packages/frontend/src/utility/achievements.ts
index 3025a985ba..f6ab587ae1 100644
--- a/packages/frontend/src/utility/achievements.ts
+++ b/packages/frontend/src/utility/achievements.ts
@@ -4,7 +4,7 @@
  */
 
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 export const ACHIEVEMENT_TYPES = [
 	'notes1',
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
index ebc67eb58d..e768d8a5ae 100644
--- a/packages/frontend/src/utility/autogen/settings-search-index.ts
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -814,6 +814,13 @@ export const searchIndexes: SearchIndexItem[] = [
 		path: '/settings/avatar-decoration',
 		icon: 'ti ti-sparkles',
 	},
+	{
+		id: 'zK6posor9',
+		label: i18n.ts.accounts,
+		keywords: ['accounts'],
+		path: '/settings/accounts',
+		icon: 'ti ti-users',
+	},
 	{
 		id: '330Q4mf8E',
 		children: [
diff --git a/packages/frontend/src/utility/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts
index ed86529d5b..2de8fd2cd1 100644
--- a/packages/frontend/src/utility/check-permissions.ts
+++ b/packages/frontend/src/utility/check-permissions.ts
@@ -4,7 +4,7 @@
  */
 
 import { instance } from '@/instance.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 export const notesSearchAvailable = (
 	// FIXME: instance.policies would be null in Vitest
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
index 74eb326c4c..d2026de0b6 100644
--- a/packages/frontend/src/utility/get-note-menu.ts
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -9,7 +9,7 @@ import { url } from '@@/js/config.js';
 import { claimAchievement } from './achievements.js';
 import type { Ref, ShallowRef } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import * as os from '@/os.js';
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index 1b9b0eac95..b89c7537e2 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -13,7 +13,7 @@ import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { $i, iAmModerator } from '@/account.js';
+import { $i, iAmModerator } from '@/i.js';
 import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
diff --git a/packages/frontend/src/utility/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts
index e28e5725bc..48ef1c4e49 100644
--- a/packages/frontend/src/utility/isFfVisibleForMe.ts
+++ b/packages/frontend/src/utility/isFfVisibleForMe.ts
@@ -4,7 +4,7 @@
  */
 
 import * as Misskey from 'misskey-js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
 	if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts
index dc07ad477b..72ba54ade3 100644
--- a/packages/frontend/src/utility/misskey-api.ts
+++ b/packages/frontend/src/utility/misskey-api.ts
@@ -6,7 +6,7 @@
 import * as Misskey from 'misskey-js';
 import { ref } from 'vue';
 import { apiUrl } from '@@/js/config.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 export const pendingApiRequestsCount = ref(0);
 
 export type Endpoint = keyof Misskey.Endpoints;
diff --git a/packages/frontend/src/utility/please-login.ts b/packages/frontend/src/utility/please-login.ts
index a8a330eb6d..9253105f48 100644
--- a/packages/frontend/src/utility/please-login.ts
+++ b/packages/frontend/src/utility/please-login.ts
@@ -4,7 +4,7 @@
  */
 
 import { defineAsyncComponent } from 'vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { popup } from '@/os.js';
diff --git a/packages/frontend/src/utility/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts
index 35b3ef79d8..db21b028cd 100644
--- a/packages/frontend/src/utility/show-moved-dialog.ts
+++ b/packages/frontend/src/utility/show-moved-dialog.ts
@@ -4,7 +4,7 @@
  */
 
 import * as os from '@/os.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 
 export function showMovedDialog() {
diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts
index d105a318a7..eb3cbd3dfa 100644
--- a/packages/frontend/src/utility/upload.ts
+++ b/packages/frontend/src/utility/upload.ts
@@ -9,7 +9,7 @@ import { v4 as uuid } from 'uuid';
 import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
 import { apiUrl } from '@@/js/config.js';
 import { getCompressionConfig } from './upload/compress-config.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { alert } from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue
index d911e71ab2..db03d1406c 100644
--- a/packages/frontend/src/widgets/WidgetActivity.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.vue
@@ -28,7 +28,7 @@ import XChart from './WidgetActivity.chart.vue';
 import type { GetFormResultType } from '@/utility/form.js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
 import MkContainer from '@/components/MkContainer.vue';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'activity';
diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue
index b49041158f..c46fd81466 100644
--- a/packages/frontend/src/widgets/WidgetAiscript.vue
+++ b/packages/frontend/src/widgets/WidgetAiscript.vue
@@ -27,7 +27,7 @@ import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'aiscript';
diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
index fb9dea1847..429b0e0ffb 100644
--- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue
+++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
@@ -21,7 +21,7 @@ import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps
 import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkAsUi from '@/components/MkAsUi.vue';
 import MkContainer from '@/components/MkContainer.vue';
 import { registerAsUiLib } from '@/aiscript/ui.js';
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 8c7507ef44..be11a26917 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -33,7 +33,7 @@ import MkContainer from '@/components/MkContainer.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { infoImageUrl } from '@/instance.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 
 const name = i18n.ts._widgets.birthdayFollowings;
 
diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue
index 3f0f9eb9fd..4afe735a22 100644
--- a/packages/frontend/src/widgets/WidgetButton.vue
+++ b/packages/frontend/src/widgets/WidgetButton.vue
@@ -18,7 +18,7 @@ import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps
 import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import MkButton from '@/components/MkButton.vue';
 
 const name = 'button';
diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue
index c86d1c9653..3fe8378a39 100644
--- a/packages/frontend/src/widgets/WidgetProfile.vue
+++ b/packages/frontend/src/widgets/WidgetProfile.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
-import { $i } from '@/account.js';
+import { $i } from '@/i.js';
 import { userPage } from '@/filters/user.js';
 
 const name = 'profile';
diff --git a/packages/frontend/test/aiscript/api.test.ts b/packages/frontend/test/aiscript/api.test.ts
index a569c0fa51..36838af163 100644
--- a/packages/frontend/test/aiscript/api.test.ts
+++ b/packages/frontend/test/aiscript/api.test.ts
@@ -33,7 +33,7 @@ async function exe(script: string): Promise<values.Value[]> {
 	return outputs;
 }
 
-let $iMock = vi.hoisted<Partial<typeof import('@/account.js').$i> | null >(
+let $iMock = vi.hoisted<Partial<typeof import('@/i.js').$i> | null >(
 	() => null
 );
 
-- 
cgit v1.2.3-freya


From c2940fd77cba7b0cf912db9fda92e66dbce9110f Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 16 Mar 2025 10:58:06 +0900
Subject: enhance(frontend): improve usability on touch device

---
 locales/index.d.ts                                      |  8 ++++++++
 locales/ja-JP.yml                                       |  2 ++
 packages/frontend/src/boot/common.ts                    |  4 ++++
 packages/frontend/src/components/MkDialog.vue           |  4 ++--
 packages/frontend/src/components/MkInfo.vue             |  2 +-
 packages/frontend/src/components/MkInput.vue            |  8 ++++----
 packages/frontend/src/components/MkInviteCode.vue       |  2 +-
 packages/frontend/src/components/MkKeyValue.vue         |  2 +-
 packages/frontend/src/components/MkNote.vue             |  3 ++-
 packages/frontend/src/components/MkNoteDetailed.vue     |  3 ++-
 packages/frontend/src/components/MkTextarea.vue         |  4 ++--
 packages/frontend/src/pages/settings/accessibility.vue  | 11 +++++++++++
 packages/frontend/src/preferences/def.ts                |  3 +++
 packages/frontend/src/style.scss                        | 17 +++++++++++++++++
 .../src/utility/autogen/settings-search-index.ts        | 13 +++++++++----
 15 files changed, 69 insertions(+), 17 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 00d275ee43..7e4892eb39 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5433,6 +5433,14 @@ export interface Locale extends ILocale {
          * タイムラインとノート
          */
         "timelineAndNote": string;
+        /**
+         * 全てのテキスト要素を選択可能にする
+         */
+        "makeEveryTextElementsSelectable": string;
+        /**
+         * 有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。
+         */
+        "makeEveryTextElementsSelectable_description": string;
     };
     "_preferencesProfile": {
         /**
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8e787fa02f..f17c5a1278 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1357,6 +1357,8 @@ _settings:
   appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。"
   soundsBanner: "クライアントで再生するサウンドの設定が行えます。"
   timelineAndNote: "タイムラインとノート"
+  makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする"
+  makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。"
 
 _preferencesProfile:
   profileName: "プロファイル名"
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 73c4256c4b..10bcddbde7 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -234,6 +234,10 @@ export async function common(createVue: () => App<Element>) {
 			});
 	}
 
+	if (prefer.s.makeEveryTextElementsSelectable) {
+		document.documentElement.classList.add('forceSelectableAll');
+	}
+
 	//#region Fetch user
 	if ($i && $i.token) {
 		if (_DEV_) {
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 6c9fa3167a..312c7ab58f 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
 			<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
 		</div>
-		<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
-		<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
+		<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
+		<div v-if="text" :class="$style.text" class="_selectable"><Mfm :text="text"/></div>
 		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
 			<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
 			<template #caption>
diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue
index 90ca1b5a9d..f114ec8a71 100644
--- a/packages/frontend/src/components/MkInfo.vue
+++ b/packages/frontend/src/components/MkInfo.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div :class="[$style.root, { [$style.warn]: warn }]">
+<div :class="[$style.root, { [$style.warn]: warn }]" class="_selectable">
 	<i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i>
 	<i v-else class="ti ti-info-circle" :class="$style.i"></i>
 	<div><slot></slot></div>
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index aff9a67020..a66e64311a 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div>
+<div class="_selectable">
 	<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
 	<div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]">
 		<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
@@ -45,13 +45,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
-import type { InputHTMLAttributes } from 'vue';
 import { debounce } from 'throttle-debounce';
-import MkButton from '@/components/MkButton.vue';
 import { useInterval } from '@@/js/use-interval.js';
+import type { InputHTMLAttributes } from 'vue';
+import type { SuggestionType } from '@/utility/autocomplete.js';
+import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { Autocomplete } from '@/utility/autocomplete.js';
-import type { SuggestionType } from '@/utility/autocomplete.js';
 
 const props = defineProps<{
 	modelValue: string | number | null;
diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue
index 08eddfc03c..0f53d1573d 100644
--- a/packages/frontend/src/components/MkInviteCode.vue
+++ b/packages/frontend/src/components/MkInviteCode.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div :class="$style.items">
 			<div>
 				<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
-				<div>{{ invite.code }}</div>
+				<div class="_selectableAtomic">{{ invite.code }}</div>
 			</div>
 			<div v-if="moderator">
 				<div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div>
diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue
index 6a4b298523..275b153e4a 100644
--- a/packages/frontend/src/components/MkKeyValue.vue
+++ b/packages/frontend/src/components/MkKeyValue.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.key">
 		<slot name="key"></slot>
 	</div>
-	<div :class="$style.value">
+	<div :class="$style.value" class="_selectable">
 		<slot name="value"></slot>
 		<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
 	</div>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index e08f5e6163..1f18b173a0 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -76,12 +76,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 							:emojiUrls="appearNote.emojis"
 							:enableEmojiMenu="true"
 							:enableEmojiMenuReaction="true"
+							class="_selectable"
 						/>
 						<div v-if="translating || translation" :class="$style.translation">
 							<MkLoading v-if="translating" mini/>
 							<div v-else-if="translation">
 								<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
-								<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+								<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
 							</div>
 						</div>
 					</div>
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index dd8d3567b2..6af8acb2df 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -97,13 +97,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 					:emojiUrls="appearNote.emojis"
 					:enableEmojiMenu="true"
 					:enableEmojiMenuReaction="true"
+					class="_selectable"
 				/>
 				<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
 				<div v-if="translating || translation" :class="$style.translation">
 					<MkLoading v-if="translating" mini/>
 					<div v-else-if="translation">
 						<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
-						<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
+						<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
 					</div>
 				</div>
 				<div v-if="appearNote.files && appearNote.files.length > 0">
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index eb47a8b858..c9da226734 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div>
+<div class="_selectable">
 	<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
 	<div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;">
 		<textarea
@@ -38,10 +38,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
 import { debounce } from 'throttle-debounce';
+import type { SuggestionType } from '@/utility/autocomplete.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { Autocomplete } from '@/utility/autocomplete.js';
-import type { SuggestionType } from '@/utility/autocomplete.js';
 
 const props = defineProps<{
 	modelValue: string | null;
diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue
index f7b1e7d2a0..e8268719f5 100644
--- a/packages/frontend/src/pages/settings/accessibility.vue
+++ b/packages/frontend/src/pages/settings/accessibility.vue
@@ -58,6 +58,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</MkSwitch>
 				</MkPreferenceContainer>
 			</SearchMarker>
+
+			<SearchMarker :keywords="['text', 'selectable']">
+				<MkPreferenceContainer k="makeEveryTextElementsSelectable">
+					<MkSwitch v-model="makeEveryTextElementsSelectable">
+						<template #label><SearchLabel>{{ i18n.ts._settings.makeEveryTextElementsSelectable }}</SearchLabel></template>
+						<template #caption>{{ i18n.ts._settings.makeEveryTextElementsSelectable_description }}</template>
+					</MkSwitch>
+				</MkPreferenceContainer>
+			</SearchMarker>
 		</div>
 
 		<SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']">
@@ -122,6 +131,7 @@ const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
 const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
 const contextMenu = prefer.model('contextMenu');
 const menuStyle = prefer.model('menuStyle');
+const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable');
 
 const fontSize = ref(miLocalStorage.getItem('fontSize'));
 const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
@@ -147,6 +157,7 @@ watch([
 	contextMenu,
 	fontSize,
 	useSystemFont,
+	makeEveryTextElementsSelectable,
 ], async () => {
 	await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
 });
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index e460359acd..048392ef49 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -313,6 +313,9 @@ export const PREF_DEF = {
 	defaultFollowWithReplies: {
 		default: false,
 	},
+	makeEveryTextElementsSelectable: {
+		default: DEFAULT_DEVICE_KIND === 'desktop',
+	},
 	plugins: {
 		default: [] as Plugin[],
 	},
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 5308c312bb..43aff3012e 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -81,6 +81,11 @@ html {
 	&.useSystemFont {
 		font-family: system-ui;
 	}
+
+	&:not(.forceSelectableAll) {
+		user-select: none;
+		-webkit-user-select: none;
+	}
 }
 
 html._themeChanging_ {
@@ -120,6 +125,8 @@ a {
 textarea, input {
 	tap-highlight-color: transparent;
 	-webkit-tap-highlight-color: transparent;
+	user-select: text;
+	-webkit-user-select: text;
 }
 
 optgroup, option {
@@ -184,6 +191,16 @@ rt {
 	padding: 0.3em 0.5em;
 }
 
+._selectable {
+	user-select: text;
+	-webkit-user-select: text;
+}
+
+._selectableAtomic {
+	user-select: all;
+	-webkit-user-select: all;
+}
+
 ._noSelect {
 	user-select: none;
 	-webkit-user-select: none;
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
index ab26d671a2..2a0a4c1857 100644
--- a/packages/frontend/src/utility/autogen/settings-search-index.ts
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -897,22 +897,27 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
 			},
 			{
-				id: '1fV9WINCQ',
+				id: 'b1GYEEJeh',
+				label: i18n.ts._settings.makeEveryTextElementsSelectable,
+				keywords: ['text', 'selectable'],
+			},
+			{
+				id: 'vVLxwINTJ',
 				label: i18n.ts.menuStyle,
 				keywords: ['menu', 'style', 'popup', 'drawer'],
 			},
 			{
-				id: 'mLQzlKUNu',
+				id: '14cMhMLHL',
 				label: i18n.ts._contextMenu.title,
 				keywords: ['contextmenu', 'system', 'native'],
 			},
 			{
-				id: 'yP96aA3j9',
+				id: 'oSo4LXMX9',
 				label: i18n.ts.fontSize,
 				keywords: ['font', 'size'],
 			},
 			{
-				id: 'jQeiMopFE',
+				id: '7LQSAThST',
 				label: i18n.ts.useSystemFont,
 				keywords: ['font', 'system', 'native'],
 			},
-- 
cgit v1.2.3-freya


From 7b323031b774745ee2146c89ead2a9ebe628d613 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 19 Mar 2025 18:46:03 +0900
Subject: refactor(frontend): use useTemplateRef for DOM referencing

---
 .../src/components/MkAbuseReportWindow.vue         |  4 ++--
 packages/frontend/src/components/MkAnimBg.vue      |  4 ++--
 .../src/components/MkAnnouncementDialog.vue        |  6 +++---
 .../src/components/MkAntennaEditorDialog.vue       |  4 ++--
 .../frontend/src/components/MkAutocomplete.vue     |  4 ++--
 packages/frontend/src/components/MkButton.vue      |  6 +++---
 packages/frontend/src/components/MkCaptcha.vue     |  4 ++--
 packages/frontend/src/components/MkChart.vue       | 14 +++++--------
 packages/frontend/src/components/MkCodeEditor.vue  |  4 ++--
 packages/frontend/src/components/MkColorInput.vue  |  4 ++--
 packages/frontend/src/components/MkContainer.vue   |  8 ++++----
 packages/frontend/src/components/MkContextMenu.vue |  4 ++--
 .../frontend/src/components/MkCropperDialog.vue    |  6 +++---
 .../src/components/MkCustomEmojiDetailedDialog.vue |  6 +++---
 packages/frontend/src/components/MkDialog.vue      |  4 ++--
 packages/frontend/src/components/MkDrive.vue       |  6 +++---
 .../src/components/MkDriveSelectDialog.vue         |  4 ++--
 .../src/components/MkEmbedCodeGenDialog.vue        |  8 ++++----
 packages/frontend/src/components/MkEmojiPicker.vue |  6 +++---
 .../src/components/MkEmojiPickerDialog.vue         |  6 +++---
 .../src/components/MkFileCaptionEditWindow.vue     |  4 ++--
 .../frontend/src/components/MkFoldableSection.vue  |  4 ++--
 packages/frontend/src/components/MkFolder.vue      |  4 ++--
 packages/frontend/src/components/MkFormDialog.vue  |  4 ++--
 packages/frontend/src/components/MkHeatmap.vue     |  6 +++---
 .../frontend/src/components/MkHorizontalSwipe.vue  |  4 ++--
 .../frontend/src/components/MkImgWithBlurhash.vue  |  8 ++++----
 packages/frontend/src/components/MkInput.vue       |  8 ++++----
 .../frontend/src/components/MkInstanceStats.vue    |  8 ++++----
 packages/frontend/src/components/MkLaunchPad.vue   |  4 ++--
 packages/frontend/src/components/MkMediaAudio.vue  |  6 +++---
 packages/frontend/src/components/MkMediaList.vue   |  4 ++--
 packages/frontend/src/components/MkMediaVideo.vue  |  6 +++---
 packages/frontend/src/components/MkMenu.child.vue  |  4 ++--
 packages/frontend/src/components/MkMenu.vue        |  8 ++++----
 packages/frontend/src/components/MkModal.vue       |  6 +++---
 packages/frontend/src/components/MkModalWindow.vue |  8 ++++----
 packages/frontend/src/components/MkNote.vue        | 16 +++++++--------
 .../frontend/src/components/MkNoteDetailed.vue     | 16 +++++++--------
 packages/frontend/src/components/MkNotes.vue       |  4 ++--
 .../src/components/MkNotificationSelectWindow.vue  |  8 ++++----
 .../frontend/src/components/MkNotifications.vue    |  4 ++--
 packages/frontend/src/components/MkOmit.vue        |  4 ++--
 packages/frontend/src/components/MkPageWindow.vue  |  4 ++--
 packages/frontend/src/components/MkPagination.vue  |  4 ++--
 .../frontend/src/components/MkPasswordDialog.vue   |  6 +++---
 packages/frontend/src/components/MkPopupMenu.vue   |  4 ++--
 packages/frontend/src/components/MkPostForm.vue    | 12 +++++------
 .../frontend/src/components/MkPostFormDialog.vue   |  7 +++----
 .../frontend/src/components/MkPullToRefresh.vue    |  6 +++---
 packages/frontend/src/components/MkRange.vue       |  6 +++---
 .../frontend/src/components/MkReactionIcon.vue     |  4 ++--
 .../src/components/MkReactionsViewer.reaction.vue  |  4 ++--
 .../frontend/src/components/MkRetentionHeatmap.vue |  6 +++---
 .../src/components/MkRetentionLineChart.vue        |  4 ++--
 .../frontend/src/components/MkSigninDialog.vue     |  4 ++--
 .../frontend/src/components/MkSignupDialog.vue     |  4 ++--
 packages/frontend/src/components/MkSparkle.vue     |  4 ++--
 .../src/components/MkSystemWebhookEditor.vue       |  8 ++++----
 packages/frontend/src/components/MkTagCloud.vue    |  8 ++++----
 packages/frontend/src/components/MkTextarea.vue    |  4 ++--
 packages/frontend/src/components/MkTimeline.vue    |  6 +++---
 .../src/components/MkTokenGenerateWindow.vue       |  4 ++--
 packages/frontend/src/components/MkTooltip.vue     |  4 ++--
 .../frontend/src/components/MkTutorialDialog.vue   |  6 +++---
 packages/frontend/src/components/MkUpdated.vue     |  6 +++---
 .../frontend/src/components/MkUserSelectDialog.vue |  4 ++--
 .../frontend/src/components/MkUserSetupDialog.vue  |  9 ++++----
 .../frontend/src/components/MkVisibilityPicker.vue |  4 ++--
 .../MkVisitorDashboard.ActiveUsersChart.vue        |  4 ++--
 .../frontend/src/components/MkWaitingDialog.vue    |  4 ++--
 packages/frontend/src/components/MkWindow.vue      |  4 ++--
 packages/frontend/src/components/global/MkA.vue    |  4 ++--
 packages/frontend/src/components/global/MkLazy.vue |  4 ++--
 .../src/components/global/MkPageHeader.tabs.vue    |  6 +++---
 .../src/components/global/MkPageHeader.vue         |  4 ++--
 .../src/components/global/MkStickyContainer.vue    |  3 +--
 .../frontend/src/components/grid/MkDataCell.vue    | 24 ++++++++++++----------
 packages/frontend/src/pages/about-misskey.vue      |  4 ++--
 packages/frontend/src/pages/admin/_header_.vue     |  6 +++---
 .../abuse-report/notification-recipient.editor.vue |  6 +++---
 packages/frontend/src/pages/admin/abuses.vue       |  4 ++--
 packages/frontend/src/pages/admin/invites.vue      |  6 +++---
 packages/frontend/src/pages/admin/modlog.vue       |  4 ++--
 .../src/pages/admin/overview.active-users.vue      |  4 ++--
 .../src/pages/admin/overview.ap-requests.vue       |  6 +++---
 packages/frontend/src/pages/admin/overview.pie.vue |  4 ++--
 .../src/pages/admin/overview.queue.chart.vue       |  4 ++--
 .../frontend/src/pages/admin/overview.queue.vue    | 10 ++++-----
 packages/frontend/src/pages/admin/overview.vue     |  4 ++--
 .../frontend/src/pages/admin/queue.chart.chart.vue |  4 ++--
 packages/frontend/src/pages/admin/queue.chart.vue  | 10 ++++-----
 packages/frontend/src/pages/admin/users.vue        |  4 ++--
 packages/frontend/src/pages/antenna-timeline.vue   |  8 ++++----
 .../frontend/src/pages/custom-emojis-manager.vue   |  4 ++--
 .../frontend/src/pages/drop-and-fusion.game.vue    |  6 +++---
 packages/frontend/src/pages/explore.users.vue      |  4 ++--
 packages/frontend/src/pages/explore.vue            |  4 ++--
 packages/frontend/src/pages/follow-requests.vue    |  4 ++--
 packages/frontend/src/pages/invite.vue             |  4 ++--
 packages/frontend/src/pages/my-clips/index.vue     |  4 ++--
 .../pages/page-editor/els/page-editor.el.text.vue  |  5 ++---
 .../frontend/src/pages/settings/2fa.qrdialog.vue   |  4 ++--
 .../pages/settings/avatar-decoration.dialog.vue    |  4 ++--
 packages/frontend/src/pages/settings/index.vue     |  4 ++--
 .../frontend/src/pages/settings/notifications.vue  |  4 ++--
 packages/frontend/src/pages/timeline.vue           |  6 +++---
 packages/frontend/src/pages/user-list-timeline.vue |  8 ++++----
 .../frontend/src/pages/user/activity.following.vue |  8 ++++----
 .../frontend/src/pages/user/activity.notes.vue     |  8 ++++----
 packages/frontend/src/pages/user/activity.pv.vue   |  8 ++++----
 .../frontend/src/pages/welcome.timeline.note.vue   |  4 ++--
 packages/frontend/src/pages/welcome.timeline.vue   |  6 +++---
 packages/frontend/src/ui/classic.sidebar.vue       |  4 ++--
 packages/frontend/src/ui/classic.vue               |  4 ++--
 packages/frontend/src/ui/deck.vue                  |  4 ++--
 packages/frontend/src/ui/deck/antenna-column.vue   |  4 ++--
 packages/frontend/src/ui/deck/channel-column.vue   |  4 ++--
 packages/frontend/src/ui/deck/column.vue           |  4 ++--
 packages/frontend/src/ui/deck/list-column.vue      |  4 ++--
 .../frontend/src/ui/deck/notifications-column.vue  |  6 +++---
 .../frontend/src/ui/deck/role-timeline-column.vue  |  4 ++--
 packages/frontend/src/ui/deck/tl-column.vue        |  4 ++--
 packages/frontend/src/ui/universal.vue             |  4 ++--
 packages/frontend/src/widgets/WidgetAichan.vue     |  4 ++--
 .../frontend/src/widgets/WidgetInstanceCloud.vue   |  8 ++++----
 packages/frontend/src/widgets/WidgetSlideshow.vue  |  8 ++++----
 127 files changed, 353 insertions(+), 359 deletions(-)

(limited to 'packages/frontend/src/components/MkNoteDetailed.vue')

diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue
index a634a748e9..dbac5e9dd7 100644
--- a/packages/frontend/src/components/MkAbuseReportWindow.vue
+++ b/packages/frontend/src/components/MkAbuseReportWindow.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkWindow from '@/components/MkWindow.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -47,7 +47,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
+const uiWindow = useTemplateRef('uiWindow');
 const comment = ref(props.initialComment ?? '');
 
 function send() {
diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue
index 4bf6125af5..2938645557 100644
--- a/packages/frontend/src/components/MkAnimBg.vue
+++ b/packages/frontend/src/components/MkAnimBg.vue
@@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef } from 'vue';
 import isChromatic from 'chromatic/isChromatic';
 
-const canvasEl = shallowRef<HTMLCanvasElement>();
+const canvasEl = useTemplateRef('canvasEl');
 
 const props = withDefaults(defineProps<{
 	scale?: number;
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 582bb137bc..6e5b29654b 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -37,8 +37,8 @@ const props = withDefaults(defineProps<{
 }>(), {
 });
 
-const rootEl = shallowRef<HTMLDivElement>();
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const rootEl = useTemplateRef('rootEl');
+const modal = useTemplateRef('modal');
 
 async function ok() {
 	if (props.announcement.needConfirmationToRead) {
diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue
index 6d815d29f3..0ebf5abf4c 100644
--- a/packages/frontend/src/components/MkAntennaEditorDialog.vue
+++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import XAntennaEditor from '@/components/MkAntennaEditor.vue';
@@ -40,7 +40,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void,
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
 	emit('created', newAntenna);
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 7436d4f1b2..5b747a2a1a 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
+import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 import sanitizeHtml from 'sanitize-html';
 import { emojilist, getEmojiName } from '@@/js/emojilist.js';
 import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
@@ -139,7 +139,7 @@ const emit = defineEmits<{
 }>();
 
 const suggests = ref<Element>();
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const fetching = ref(true);
 const users = ref<any[]>([]);
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 5b32ec0fc8..c81edc2a73 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, shallowRef } from 'vue';
+import { nextTick, onMounted, useTemplateRef } from 'vue';
 
 const props = defineProps<{
 	type?: 'button' | 'submit' | 'reset';
@@ -64,8 +64,8 @@ const emit = defineEmits<{
 	(ev: 'click', payload: MouseEvent): void;
 }>();
 
-const el = shallowRef<HTMLElement | null>(null);
-const ripples = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
+const ripples = useTemplateRef('ripples');
 
 onMounted(() => {
 	if (props.autofocus) {
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 30eafb7a5b..e4f953bda8 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
+import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
 import { store } from '@/store.js';
 
 // APIs provided by Captcha services
@@ -69,7 +69,7 @@ const emit = defineEmits<{
 
 const available = ref(false);
 
-const captchaEl = shallowRef<HTMLDivElement | undefined>();
+const captchaEl = useTemplateRef('captchaEl');
 const captchaWidgetId = ref<string | undefined>(undefined);
 const testcaptchaInput = ref('');
 const testcaptchaPassed = ref(false);
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index d9f4558014..7e164362c1 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -45,12 +45,8 @@ export type ChartSrc =
 </script>
 
 <script lang="ts" setup>
-/* eslint-disable id-denylist --
-  Chart.js has a `data` attribute in most chart definitions, which triggers the
-  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 { onMounted, ref, shallowRef, watch } from 'vue';
+
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
@@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{
 	nowForChromatic: undefined,
 });
 
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const legendEl = useTemplateRef('legendEl');
 
 const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
 const negate = arr => arr.map(x => -x);
@@ -134,7 +130,7 @@ let chartData: {
 	bytes?: boolean;
 } | null = null;
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 const fetching = ref(true);
 
 const getDate = (ago: number) => {
@@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender);
 onMounted(() => {
 	fetchAndRender();
 });
-/* eslint-enable id-denylist */
+
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue
index 5bf2301e72..46fdf15b5d 100644
--- a/packages/frontend/src/components/MkCodeEditor.vue
+++ b/packages/frontend/src/components/MkCodeEditor.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
+import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue';
 import { debounce } from 'throttle-debounce';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
@@ -61,7 +61,7 @@ const { modelValue } = toRefs(props);
 const v = ref<string>(modelValue.value ?? '');
 const focused = ref(false);
 const changed = ref(false);
-const inputEl = shallowRef<HTMLTextAreaElement>();
+const inputEl = useTemplateRef('inputEl');
 
 const focus = () => inputEl.value?.focus();
 
diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue
index 55a32664de..50931cc318 100644
--- a/packages/frontend/src/components/MkColorInput.vue
+++ b/packages/frontend/src/components/MkColorInput.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, toRefs } from 'vue';
+import { ref, useTemplateRef, toRefs } from 'vue';
 
 const props = defineProps<{
 	modelValue: string | null;
@@ -39,7 +39,7 @@ const emit = defineEmits<{
 
 const { modelValue } = toRefs(props);
 const v = ref(modelValue.value);
-const inputEl = shallowRef<HTMLElement>();
+const inputEl = useTemplateRef('inputEl');
 
 const onInput = () => {
 	emit('update:modelValue', v.value ?? '');
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index 169f3df7a4..39ca39aad7 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 
@@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{
 	maxHeight: null,
 });
 
-const rootEl = shallowRef<HTMLElement>();
-const contentEl = shallowRef<HTMLElement>();
-const headerEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const contentEl = useTemplateRef('contentEl');
+const headerEl = useTemplateRef('headerEl');
 const showBody = ref(props.expanded);
 const ignoreOmit = ref(false);
 const omitted = ref(false);
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index 46da0840fa..e47dba4bae 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
+import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
 import contains from '@/utility/contains.js';
@@ -34,7 +34,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const zIndex = ref<number>(os.claimZIndex('high'));
 
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 4a89fb30ca..bd2e6a9cbc 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import Cropper from 'cropperjs';
 import tinycolor from 'tinycolor2';
@@ -56,8 +56,8 @@ const props = defineProps<{
 }>();
 
 const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
-const imgEl = shallowRef<HTMLImageElement>();
+const dialogEl = useTemplateRef('dialogEl');
+const imgEl = useTemplateRef('imgEl');
 let cropper: Cropper | null = null;
 const loading = ref(true);
 
diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
index 86d6269c69..54fda6bf7c 100644
--- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
+++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
@@ -57,14 +57,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkLink from '@/components/MkLink.vue';
 import { i18n } from '@/i18n.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 
 const props = defineProps<{
-  emoji: Misskey.entities.EmojiDetailed,
+	emoji: Misskey.entities.EmojiDetailed,
 }>();
 
 const emit = defineEmits<{
@@ -73,7 +73,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function cancel() {
 	emit('cancel');
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 312c7ab58f..81d508c161 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed } from 'vue';
+import { ref, useTemplateRef, computed } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -117,7 +117,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const inputValue = ref<string | number | null>(props.input?.default ?? null);
 const selectedValue = ref(props.select?.default ?? null);
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 270ef51916..edbdf1cca1 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
+import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkButton from './MkButton.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -129,8 +129,8 @@ const emit = defineEmits<{
 	(ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
 }>();
 
-const loadMoreFiles = shallowRef<InstanceType<typeof MkButton>>();
-const fileInput = shallowRef<HTMLInputElement>();
+const loadMoreFiles = useTemplateRef('loadMoreFiles');
+const fileInput = useTemplateRef('fileInput');
 
 const folder = ref<Misskey.entities.DriveFolder | null>(null);
 const files = ref<Misskey.entities.DriveFile[]>([]);
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue
index f1ecc27123..1b9455e3f3 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.vue
+++ b/packages/frontend/src/components/MkDriveSelectDialog.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XDrive from '@/components/MkDrive.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
@@ -43,7 +43,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
 
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index e038b84ba7..a4e9547f90 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
+import { useTemplateRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
 import { url } from '@@/js/config.js';
 import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
 import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
@@ -121,7 +121,7 @@ const props = defineProps<{
 }>();
 
 //#region Modalの制御
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function cancel() {
 	emit('cancel');
@@ -198,9 +198,9 @@ function doCopy() {
 //#endregion
 
 //#region プレビューのリサイズ
-const resizerRootEl = shallowRef<HTMLDivElement>();
+const resizerRootEl = useTemplateRef('resizerRootEl');
 const iframeLoading = ref(true);
-const iframeEl = shallowRef<HTMLIFrameElement>();
+const iframeEl = useTemplateRef('iframeEl');
 const iframeHeight = ref(0);
 const iframeScale = ref(1);
 const iframeStyle = computed(() => {
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index c0883b1342..d4367f6ee8 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, watch, onMounted } from 'vue';
+import { ref, useTemplateRef, computed, watch, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import {
 	emojilist,
@@ -157,8 +157,8 @@ const emit = defineEmits<{
 	(ev: 'esc'): void;
 }>();
 
-const searchEl = shallowRef<HTMLInputElement>();
-const emojisEl = shallowRef<HTMLDivElement>();
+const searchEl = useTemplateRef('searchEl');
+const emojisEl = useTemplateRef('emojisEl');
 
 const {
 	emojiPickerScale,
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index 6d7062b41c..4978933f73 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
 import { prefer } from '@/preferences.js';
@@ -64,8 +64,8 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
+const modal = useTemplateRef('modal');
+const picker = useTemplateRef('picker');
 
 function chosen(emoji: string) {
 	emit('done', emoji);
diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
index 76bb965101..120d5355b2 100644
--- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue
+++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -42,7 +42,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const caption = ref(props.default);
 
diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue
index 554586b2f3..b9888d9b64 100644
--- a/packages/frontend/src/components/MkFoldableSection.vue
+++ b/packages/frontend/src/components/MkFoldableSection.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/utility/get-bg-color.js';
@@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{
 	persistKey: null,
 });
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const parentBg = ref<string | null>(null);
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 1e2057d28e..b13972f66d 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, ref, shallowRef } from 'vue';
+import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
 import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/utility/get-bg-color.js';
 
@@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{
 	spacerMax: 22,
 });
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const bgSame = ref(false);
 const opened = ref(props.defaultOpen);
 const openedAtLeastOnce = ref(props.defaultOpen);
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index df39f34816..4756079e76 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { reactive, shallowRef } from 'vue';
+import { reactive, useTemplateRef } from 'vue';
 import MkInput from './MkInput.vue';
 import MkTextarea from './MkTextarea.vue';
 import MkSwitch from './MkSwitch.vue';
@@ -99,7 +99,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const values = reactive({});
 
 for (const item in props.form) {
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 8a84bfd541..28bb936755 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
+import { onMounted, nextTick, watch, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -35,8 +35,8 @@ const props = withDefaults(defineProps<{
 	label: '',
 });
 
-const rootEl = shallowRef<HTMLDivElement | null>(null);
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart | null = null;
 const fetching = ref(true);
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
index ad85660fc4..849136eb8f 100644
--- a/packages/frontend/src/components/MkHorizontalSwipe.vue
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -26,12 +26,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </div>
 </template>
 <script lang="ts" setup>
-import { ref, shallowRef, computed, nextTick, watch } from 'vue';
+import { ref, useTemplateRef, computed, nextTick, watch } from 'vue';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
 import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
 import { prefer } from '@/preferences.js';
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const tabModel = defineModel<string>('tab');
 
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 6585784e42..b86aca19fd 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -83,7 +83,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
 </script>
 
 <script lang="ts" setup>
-import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
+import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
 import { v4 as uuid } from 'uuid';
 import { render } from 'buraha';
 import { prefer } from '@/preferences.js';
@@ -120,9 +120,9 @@ const props = withDefaults(defineProps<{
 });
 
 const viewId = uuid();
-const canvas = shallowRef<HTMLCanvasElement>();
-const root = shallowRef<HTMLDivElement>();
-const img = shallowRef<HTMLImageElement>();
+const canvas = useTemplateRef('canvas');
+const root = useTemplateRef('root');
+const img = useTemplateRef('img');
 const loaded = ref(false);
 const canvasWidth = ref(64);
 const canvasHeight = ref(64);
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index a66e64311a..49cb5cb544 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
+import { onMounted, onUnmounted, nextTick, ref, useTemplateRef, watch, computed, toRefs } from 'vue';
 import { debounce } from 'throttle-debounce';
 import { useInterval } from '@@/js/use-interval.js';
 import type { InputHTMLAttributes } from 'vue';
@@ -92,9 +92,9 @@ const focused = ref(false);
 const changed = ref(false);
 const invalid = ref(false);
 const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = shallowRef<HTMLInputElement>();
-const prefixEl = shallowRef<HTMLElement>();
-const suffixEl = shallowRef<HTMLElement>();
+const inputEl = useTemplateRef('inputEl');
+const prefixEl = useTemplateRef('prefixEl');
+const suffixEl = useTemplateRef('suffixEl');
 const height =
 	props.small ? 33 :
 	props.large ? 39 :
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 3113c6fad6..76bec9bc66 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -84,8 +84,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, computed, shallowRef } from 'vue';
+import { onMounted, ref, computed, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
+import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -95,7 +96,6 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import MkHeatmap from '@/components/MkHeatmap.vue';
-import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
 import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
@@ -109,8 +109,8 @@ const chartLimit = 500;
 const chartSpan = ref<'hour' | 'day'>('hour');
 const chartSrc = ref('active-users');
 const heatmapSrc = ref<HeatmapSource>('active-users');
-const subDoughnutEl = shallowRef<HTMLCanvasElement>();
-const pubDoughnutEl = shallowRef<HTMLCanvasElement>();
+const subDoughnutEl = useTemplateRef('subDoughnutEl');
+const pubDoughnutEl = useTemplateRef('pubDoughnutEl');
 
 const { handler: externalTooltipHandler1 } = useChartTooltip({
 	position: 'middle',
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 9a78eb67f2..3e5a88a170 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import { navbarItemDef } from '@/navbar.js';
 import { deviceKind } from '@/utility/device-kind.js';
@@ -48,7 +48,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop
 	deviceKind === 'smartphone' ? 'drawer' :
 	'dialog';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const menu = prefer.s.menu;
 
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 28860d0cf6..db6deb161a 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -88,7 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
+import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
@@ -151,8 +151,8 @@ function hasFocus() {
 	return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
 }
 
-const playerEl = shallowRef<HTMLDivElement>();
-const audioEl = shallowRef<HTMLAudioElement>();
+const playerEl = useTemplateRef('playerEl');
+const audioEl = useTemplateRef('audioEl');
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 8a7a423869..3c066650d9 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
+import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
@@ -46,7 +46,7 @@ const props = defineProps<{
 	raw?: boolean;
 }>();
 
-const gallery = shallowRef<HTMLDivElement>();
+const gallery = useTemplateRef('gallery');
 const pswpZIndex = os.claimZIndex('middle');
 document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
 const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 9674cb4461..5475e703a5 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -109,7 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
+import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
@@ -299,8 +299,8 @@ async function toggleSensitive(file: Misskey.entities.DriveFile) {
 }
 
 // MediaControl: Video State
-const videoEl = shallowRef<HTMLVideoElement>();
-const playerEl = shallowRef<HTMLDivElement>();
+const videoEl = useTemplateRef('videoEl');
+const playerEl = useTemplateRef('playerEl');
 const isHoverring = ref(false);
 const controlsShowing = computed(() => {
 	if (!oncePlayed.value) return true;
diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue
index 086573ba6d..f7cd72b6c6 100644
--- a/packages/frontend/src/components/MkMenu.child.vue
+++ b/packages/frontend/src/components/MkMenu.child.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
+import { nextTick, onMounted, onUnmounted, provide, useTemplateRef, watch } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
 
@@ -28,7 +28,7 @@ const emit = defineEmits<{
 
 provide('isNestingMenu', true);
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const align = 'left';
 
 const SCROLLBAR_THICKNESS = 16;
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index e3c27c5f6e..5438dac3a8 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -179,7 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch } from 'vue';
 import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
 import MkSwitchButton from '@/components/MkSwitch.button.vue';
@@ -212,11 +212,11 @@ const big = isTouchUsing;
 
 const isNestingMenu = inject<boolean>('isNestingMenu', false);
 
-const itemsEl = shallowRef<HTMLElement>();
+const itemsEl = useTemplateRef('itemsEl');
 
 const items2 = ref<InnerMenuItem[]>();
 
-const child = shallowRef<InstanceType<typeof XChild>>();
+const child = useTemplateRef('child');
 
 const keymap = {
 	'up|k|shift+tab': {
@@ -257,7 +257,7 @@ watch(() => props.items, () => {
 });
 
 const childMenu = ref<MenuItem[] | null>();
-const childTarget = shallowRef<HTMLElement | null>();
+const childTarget = useTemplateRef('childTarget');
 
 function closeChild() {
 	childMenu.value = null;
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 8b3086d55e..b5c93df4ed 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue';
+import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, useTemplateRef, computed } from 'vue';
 import type { Keymap } from '@/utility/hotkey.js';
 import * as os from '@/os.js';
 import { isTouchUsing } from '@/utility/touch.js';
@@ -100,8 +100,8 @@ const maxHeight = ref<number>();
 const fixed = ref(false);
 const transformOrigin = ref('center');
 const showing = ref(true);
-const modalRootEl = shallowRef<HTMLElement>();
-const content = shallowRef<HTMLElement>();
+const modalRootEl = useTemplateRef('modalRootEl');
+const content = useTemplateRef('content');
 const zIndex = os.claimZIndex(props.zPriority);
 const useSendAnime = ref(false);
 const type = computed<ModalTypes>(() => {
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index f06cfffee4..19989e375b 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
 import MkModal from './MkModal.vue';
 
 const props = withDefaults(defineProps<{
@@ -47,9 +47,9 @@ const emit = defineEmits<{
 	(event: 'esc'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const rootEl = shallowRef<HTMLElement>();
-const headerEl = shallowRef<HTMLElement>();
+const modal = useTemplateRef('modal');
+const rootEl = useTemplateRef('rootEl');
+const headerEl = useTemplateRef('headerEl');
 const bodyWidth = ref(0);
 const bodyHeight = ref(0);
 
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 1f18b173a0..73ff85b150 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -178,7 +178,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, ref, shallowRef, watch, provide } from 'vue';
+import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
@@ -271,14 +271,14 @@ if (noteViewInterruptors.length > 0) {
 
 const isRenote = Misskey.note.isPureRenote(note.value);
 
-const rootEl = shallowRef<HTMLElement>();
-const menuButton = shallowRef<HTMLElement>();
-const renoteButton = shallowRef<HTMLElement>();
-const renoteTime = shallowRef<HTMLElement>();
-const reactButton = shallowRef<HTMLElement>();
-const clipButton = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const menuButton = useTemplateRef('menuButton');
+const renoteButton = useTemplateRef('renoteButton');
+const renoteTime = useTemplateRef('renoteTime');
+const reactButton = useTemplateRef('reactButton');
+const clipButton = useTemplateRef('clipButton');
 const appearNote = computed(() => getAppearNote(note.value));
-const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
+const galleryEl = useTemplateRef('galleryEl');
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 6af8acb2df..4f74432041 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -211,7 +211,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
+import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
@@ -290,14 +290,14 @@ if (noteViewInterruptors.length > 0) {
 
 const isRenote = Misskey.note.isPureRenote(note.value);
 
-const rootEl = shallowRef<HTMLElement>();
-const menuButton = shallowRef<HTMLElement>();
-const renoteButton = shallowRef<HTMLElement>();
-const renoteTime = shallowRef<HTMLElement>();
-const reactButton = shallowRef<HTMLElement>();
-const clipButton = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const menuButton = useTemplateRef('menuButton');
+const renoteButton = useTemplateRef('renoteButton');
+const renoteTime = useTemplateRef('renoteTime');
+const reactButton = useTemplateRef('reactButton');
+const clipButton = useTemplateRef('clipButton');
 const appearNote = computed(() => getAppearNote(note.value));
-const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
+const galleryEl = useTemplateRef('galleryEl');
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const isDeleted = ref(false);
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index 62d4350400..cee226ee14 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkNote from '@/components/MkNote.vue';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@@ -46,7 +46,7 @@ const props = defineProps<{
 	disableAutoLoad?: boolean;
 }>();
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 defineExpose({
 	pagingComponent,
diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue
index a0fb7fea83..d074dceb2f 100644
--- a/packages/frontend/src/components/MkNotificationSelectWindow.vue
+++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue
@@ -30,13 +30,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
-import type { Ref } from 'vue';
+import { ref, useTemplateRef } from 'vue';
+import { notificationTypes } from '@@/js/const.js';
 import MkSwitch from './MkSwitch.vue';
 import MkInfo from './MkInfo.vue';
 import MkButton from './MkButton.vue';
+import type { Ref } from 'vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
-import { notificationTypes } from '@@/js/const.js';
 import { i18n } from '@/i18n.js';
 
 type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>;
@@ -52,7 +52,7 @@ const props = withDefaults(defineProps<{
 	excludeTypes: () => [],
 });
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap);
 
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 9f75a75fb6..08fc846327 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
+import { onUnmounted, onDeactivated, onMounted, computed, useTemplateRef, onActivated } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { notificationTypes } from '@@/js/const.js';
 import MkPagination from '@/components/MkPagination.vue';
@@ -41,7 +41,7 @@ const props = defineProps<{
 	excludeTypes?: typeof notificationTypes[number][];
 }>();
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
 	endpoint: 'i/notifications-grouped' as const,
diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue
index a05176e2f4..04276b47fe 100644
--- a/packages/frontend/src/components/MkOmit.vue
+++ b/packages/frontend/src/components/MkOmit.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
@@ -22,7 +22,7 @@ const props = withDefaults(defineProps<{
 	maxHeight: 200,
 });
 
-const content = shallowRef<HTMLElement>();
+const content = useTemplateRef('content');
 const omitted = ref(false);
 const ignoreOmit = ref(false);
 
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 4fbdc51d22..5cb00c5292 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
+import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue';
 import { url } from '@@/js/config.js';
 import type { PageMetadata } from '@/page.js';
 import RouterView from '@/components/global/RouterView.vue';
@@ -57,7 +57,7 @@ const emit = defineEmits<{
 const windowRouter = createRouter(props.initialPath);
 
 const pageMetadata = ref<null | PageMetadata>(null);
-const windowEl = shallowRef<InstanceType<typeof MkWindow>>();
+const windowEl = useTemplateRef('windowEl');
 const history = ref<{ path: string; }[]>([{
 	path: windowRouter.getCurrentFullPath(),
 }]);
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 071dbba5eb..6a1a91a9f4 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
+import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
@@ -106,7 +106,7 @@ const emit = defineEmits<{
 	(ev: 'status', error: boolean): void;
 }>();
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 
 // 遡り中かどうか
 const backed = ref(false);
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index 2068ca39a1..2abf8669ed 100644
--- a/packages/frontend/src/components/MkPasswordDialog.vue
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
@@ -54,8 +54,8 @@ const emit = defineEmits<{
 	(ev: 'cancelled'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
-const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
+const dialog = useTemplateRef('dialog');
+const passwordInput = useTemplateRef('passwordInput');
 const password = ref('');
 const isBackupCode = ref(false);
 const token = ref<string | null>(null);
diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue
index df664e49f7..232cc005e1 100644
--- a/packages/frontend/src/components/MkPopupMenu.vue
+++ b/packages/frontend/src/components/MkPopupMenu.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import MkModal from './MkModal.vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -28,7 +28,7 @@ const emit = defineEmits<{
 	(ev: 'closing'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 const manualShowing = ref(true);
 const hiding = ref(false);
 
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6e941f75be..0d37d973f0 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue';
+import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import insertTextAtCursor from 'insert-text-at-cursor';
@@ -165,11 +165,11 @@ const emit = defineEmits<{
 	(ev: 'fileChangeSensitive', fileId: string, to: boolean): void;
 }>();
 
-const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
-const cwInputEl = shallowRef<HTMLInputElement | null>(null);
-const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
-const visibilityButton = shallowRef<HTMLElement>();
-const otherSettingsButton = shallowRef<HTMLElement>();
+const textareaEl = useTemplateRef('textareaEl');
+const cwInputEl = useTemplateRef('cwInputEl');
+const hashtagsInputEl = useTemplateRef('hashtagsInputEl');
+const visibilityButton = useTemplateRef('visibilityButton');
+const otherSettingsButton = useTemplateRef('otherSettingsButton');
 
 const posting = ref(false);
 const posted = ref(false);
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 4bef5824fe..c467e29df6 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
+import type { PostFormProps } from '@/types/post-form.js';
 import MkModal from '@/components/MkModal.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
-import type { PostFormProps } from '@/types/post-form.js';
 
 const props = withDefaults(defineProps<PostFormProps & {
 	instant?: boolean;
@@ -42,8 +42,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const form = shallowRef<InstanceType<typeof MkPostForm>>();
+const modal = useTemplateRef('modal');
 
 function onPosted() {
 	modal.value?.close({
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index af4e2a4fe0..1fbf00d212 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
-import { i18n } from '@/i18n.js';
+import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import { getScrollContainer } from '@@/js/scroll.js';
+import { i18n } from '@/i18n.js';
 import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
 
 const SCROLL_STOP = 10;
@@ -43,7 +43,7 @@ const pullDistance = ref(0);
 let supportPointerDesktop = false;
 let startScreenY: number | null = null;
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 let scrollEl: HTMLElement | null = null;
 
 let disabled = false;
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 8e5be38e03..118dbbe15a 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import { isTouchUsing } from '@/utility/touch.js';
 import * as os from '@/os.js';
 
@@ -58,8 +58,8 @@ const emit = defineEmits<{
 	(ev: 'dragEnded', value: number): void;
 }>();
 
-const containerEl = shallowRef<HTMLElement>();
-const thumbEl = shallowRef<HTMLElement>();
+const containerEl = useTemplateRef('containerEl');
+const thumbEl = useTemplateRef('thumbEl');
 
 const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
 const steppedRawValue = computed(() => {
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index 7551524246..453253f0fc 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, shallowRef } from 'vue';
+import { defineAsyncComponent, useTemplateRef } from 'vue';
 import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 
@@ -20,7 +20,7 @@ const props = defineProps<{
 	withTooltip?: boolean;
 }>();
 
-const elRef = shallowRef();
+const elRef = useTemplateRef('elRef');
 
 if (props.withTooltip) {
 	useTooltip(elRef, (showing) => {
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 062894314d..590b0f6f19 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, shallowRef, watch } from 'vue';
+import { computed, inject, onMounted, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { getUnicodeEmoji } from '@@/js/emojilist.js';
 import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
@@ -50,7 +50,7 @@ const emit = defineEmits<{
 	(ev: 'reactionToggled', emoji: string, newCount: number): void;
 }>();
 
-const buttonEl = shallowRef<HTMLElement>();
+const buttonEl = useTemplateRef('buttonEl');
 
 const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
 const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index a962b37e4a..1ab2397337 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, nextTick, shallowRef, ref } from 'vue';
+import { onMounted, nextTick, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
@@ -23,8 +23,8 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const rootEl = shallowRef<HTMLDivElement | null>(null);
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const chartEl = useTemplateRef('chartEl');
 let chartInstance: Chart | null = null;
 const fetching = ref(true);
 
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 6fa19efed5..60b3f17b14 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
 import { store } from '@/store.js';
@@ -20,7 +20,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
index a970d1ce00..f122da7468 100644
--- a/packages/frontend/src/components/MkSigninDialog.vue
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
 import MkSignin from '@/components/MkSignin.vue';
 import MkModal from '@/components/MkModal.vue';
@@ -46,7 +46,7 @@ const emit = defineEmits<{
 	(ev: 'cancelled'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 function onClose() {
 	emit('cancelled');
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 6fb9d77837..5c9047dd43 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XSignup from '@/components/MkSignupDialog.form.vue';
 import XServerRules from '@/components/MkSignupDialog.rules.vue';
@@ -52,7 +52,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const isAcceptedServerRule = ref(false);
 
diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue
index b3fc67c0df..2400c5ec7f 100644
--- a/packages/frontend/src/components/MkSparkle.vue
+++ b/packages/frontend/src/components/MkSparkle.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 
 const particles = ref<{
 	id: string,
@@ -66,7 +66,7 @@ const particles = ref<{
 	dur: number,
 	color: string
 }[]>([]);
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const width = ref(0);
 const height = ref(0);
 const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202'];
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index 2e1a96c326..f819f82923 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -92,15 +92,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
+import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkInput from '@/components/MkInput.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
 import type {
 	MkSystemWebhookEditorProps,
 	MkSystemWebhookResult,
 	SystemWebhookEventType,
 } from '@/components/MkSystemWebhookEditor.impl.js';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -122,7 +122,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 const props = defineProps<MkSystemWebhookEditorProps>();
 
diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue
index 87aa046963..8ad9e14015 100644
--- a/packages/frontend/src/components/MkTagCloud.vue
+++ b/packages/frontend/src/components/MkTagCloud.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue';
+import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
 import tinycolor from 'tinycolor2';
 
 const loaded = !!window.TagCanvas;
@@ -24,9 +24,9 @@ const computedStyle = getComputedStyle(document.documentElement);
 const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const available = ref(false);
-const rootEl = shallowRef<HTMLElement | null>(null);
-const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
-const tagsEl = shallowRef<HTMLElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const canvasEl = useTemplateRef('canvasEl');
+const tagsEl = useTemplateRef('tagsEl');
 const width = ref(300);
 
 watch(available, () => {
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index c9da226734..14ffdae663 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue';
 import { debounce } from 'throttle-debounce';
 import type { SuggestionType } from '@/utility/autocomplete.js';
 import MkButton from '@/components/MkButton.vue';
@@ -75,7 +75,7 @@ const focused = ref(false);
 const changed = ref(false);
 const invalid = ref(false);
 const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = shallowRef<HTMLTextAreaElement>();
+const inputEl = useTemplateRef('inputEl');
 const preview = ref(false);
 let autocompleteWorker: Autocomplete | null = null;
 
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index e8b740eae2..73057e4644 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue';
+import { computed, watch, onUnmounted, provide, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { BasicTimelineType } from '@/timelines.js';
 import type { Paging } from '@/components/MkPagination.vue';
@@ -67,8 +67,8 @@ type TimelineQueryType = {
 	roleId?: string
 };
 
-const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>();
-const tlComponent = shallowRef<InstanceType<typeof MkNotes>>();
+const prComponent = useTemplateRef('prComponent');
+const tlComponent = useTemplateRef('tlComponent');
 
 let tlNotesCount = 0;
 
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
index bed15031cb..b449155edb 100644
--- a/packages/frontend/src/components/MkTokenGenerateWindow.vue
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkInput from './MkInput.vue';
 import MkSwitch from './MkSwitch.vue';
@@ -77,7 +77,7 @@ const emit = defineEmits<{
 const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin'));
 const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const name = ref(props.initialName);
 const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
 const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
index 8031abd563..ddfa6def87 100644
--- a/packages/frontend/src/components/MkTooltip.vue
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
+import { nextTick, onMounted, onUnmounted, useTemplateRef } from 'vue';
 import * as os from '@/os.js';
 import { calcPopupPosition } from '@/utility/popup-position.js';
 import { prefer } from '@/preferences.js';
@@ -51,7 +51,7 @@ const emit = defineEmits<{
 // タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる
 if (!props.showing) emit('closed');
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const zIndex = os.claimZIndex('high');
 
 function setPosition() {
diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue
index 5908db33c4..3e91baada4 100644
--- a/packages/frontend/src/components/MkTutorialDialog.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.vue
@@ -148,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, watch } from 'vue';
+import { ref, useTemplateRef, watch } from 'vue';
+import { host } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkButton from '@/components/MkButton.vue';
 import XNote from '@/components/MkTutorialDialog.Note.vue';
@@ -158,7 +159,6 @@ import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue';
 import MkAnimBg from '@/components/MkAnimBg.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { host } from '@@/js/config.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import * as os from '@/os.js';
 
@@ -170,7 +170,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const page = ref(props.initialPage ?? 0);
diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue
index 95732d5796..79ab464cb0 100644
--- a/packages/frontend/src/components/MkUpdated.vue
+++ b/packages/frontend/src/components/MkUpdated.vue
@@ -15,15 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
+import { version } from '@@/js/config.js';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkSparkle from '@/components/MkSparkle.vue';
-import { version } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { confetti } from '@/utility/confetti.js';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 function whatIsNew() {
 	modal.value?.close();
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index 6bf3eb44dc..057af49a36 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, computed, shallowRef } from 'vue';
+import { onMounted, ref, computed, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { host as currentHost, hostname } from '@@/js/config.js';
 import MkInput from '@/components/MkInput.vue';
@@ -94,7 +94,7 @@ const host = ref('');
 const users = ref<Misskey.entities.UserLite[]>([]);
 const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
 const selected = ref<Misskey.entities.UserLite | null>(null);
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function search() {
 	if (username.value === '' && host.value === '') {
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
index eb3a69217e..767f5c591a 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -128,7 +128,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue';
+import { ref, useTemplateRef, watch, nextTick, defineAsyncComponent } from 'vue';
+import { host } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkButton from '@/components/MkButton.vue';
 import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
@@ -137,7 +138,6 @@ import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue';
 import MkAnimBg from '@/components/MkAnimBg.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { host } from '@@/js/config.js';
 import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
@@ -146,9 +146,8 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
-
-// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const dialog = useTemplateRef('dialog');
+ 
 const page = ref(store.s.accountSetupWizard);
 
 watch(page, () => {
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
index 650e639c4f..cb402b1a57 100644
--- a/packages/frontend/src/components/MkVisibilityPicker.vue
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, shallowRef, ref } from 'vue';
+import { nextTick, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModal from '@/components/MkModal.vue';
 import { i18n } from '@/i18n.js';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const props = withDefaults(defineProps<{
 	currentVisibility: typeof Misskey.noteVisibilities[number];
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index 4168876f08..2c2c515032 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref, nextTick } from 'vue';
+import { onMounted, useTemplateRef, ref, nextTick } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
@@ -25,7 +25,7 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart | null = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue
index 34fa6b0723..282da00ee1 100644
--- a/packages/frontend/src/components/MkWaitingDialog.vue
+++ b/packages/frontend/src/components/MkWaitingDialog.vue
@@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, shallowRef } from 'vue';
+import { watch, useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const props = defineProps<{
 	success: boolean;
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 4b3c728ee4..98ec448a8e 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
+import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import contains from '@/utility/contains.js';
 import * as os from '@/os.js';
@@ -114,7 +114,7 @@ const emit = defineEmits<{
 
 provide('inWindow', true);
 
-const rootEl = shallowRef<HTMLElement | null>();
+const rootEl = useTemplateRef('rootEl');
 const showing = ref(true);
 let beforeClickedAt = 0;
 const maximized = ref(false);
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 3403418991..336160ec17 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -14,7 +14,7 @@ export type MkABehavior = 'window' | 'browser' | null;
 </script>
 
 <script lang="ts" setup>
-import { computed, inject, shallowRef } from 'vue';
+import { computed, inject, useTemplateRef } from 'vue';
 import { url } from '@@/js/config.js';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
@@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{
 
 const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null);
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 
 defineExpose({ $el: el });
 
diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue
index f35932ae77..b352296469 100644
--- a/packages/frontend/src/components/global/MkLazy.vue
+++ b/packages/frontend/src/components/global/MkLazy.vue
@@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue';
+import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, useTemplateRef } from 'vue';
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 const showing = ref(false);
 
 const observer = new IntersectionObserver(
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index 8a5e556293..81adc07f26 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -53,7 +53,7 @@ export type Tab = {
 </script>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
+import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
@@ -69,9 +69,9 @@ const emit = defineEmits<{
 	(ev: 'tabClick', key: string);
 }>();
 
-const el = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
+const tabHighlightEl = useTemplateRef('tabHighlightEl');
 const tabRefs: Record<string, HTMLElement | null> = {};
-const tabHighlightEl = shallowRef<HTMLElement | null>(null);
 
 function onTabMousedown(tab: Tab, ev: MouseEvent): void {
 	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index b353269fef..4321d69253 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue';
+import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue';
 import tinycolor from 'tinycolor2';
 import { scrollToTop } from '@@/js/scroll.js';
 import XTabs from './MkPageHeader.tabs.vue';
@@ -75,7 +75,7 @@ const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMe
 const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
 const thin_ = props.thin || inject('shouldHeaderThin', false);
 
-const el = shallowRef<HTMLElement | undefined>(undefined);
+const el = useTemplateRef('el');
 const bg = ref<string | undefined>(undefined);
 const narrow = ref(false);
 const hasTabs = computed(() => props.tabs.length > 0);
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 7ee3952083..7dda4b2f8a 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue';
-import type { Ref } from 'vue';
-
 import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
+import type { Ref } from 'vue';
 
 const rootEl = useTemplateRef('rootEl');
 const headerEl = useTemplateRef('headerEl');
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index f7f6f5c140..7c8a5d64d7 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 					{{ cell.value }}
 				</div>
 				<div v-else-if="cellType === 'boolean'">
-					<div :class="[$style.bool, {
-						[$style.boolTrue]: cell.value === true,
-						'ti ti-check': cell.value === true,
-					}]"></div>
+					<div
+						:class="[$style.bool, {
+							[$style.boolTrue]: cell.value === true,
+							'ti ti-check': cell.value === true,
+						}]"
+					></div>
 				</div>
 				<div v-else-if="cellType === 'image'">
 					<img
@@ -88,14 +90,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
+import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, useTemplateRef, toRefs, watch } from 'vue';
+import type { Size } from '@/components/grid/grid.js';
+import type { CellValue, GridCell } from '@/components/grid/cell.js';
+import type { GridRowSetting } from '@/components/grid/row.js';
 import { GridEventEmitter } from '@/components/grid/grid.js';
 import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
-import type { Size } from '@/components/grid/grid.js';
-import type { CellValue, GridCell } from '@/components/grid/cell.js';
-import type { GridRowSetting } from '@/components/grid/row.js';
 
 const emit = defineEmits<{
 	(ev: 'operation:beginEdit', sender: GridCell): void;
@@ -111,9 +113,9 @@ const props = defineProps<{
 
 const { cell, bus } = toRefs(props);
 
-const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
-const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
-const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+const rootEl = useTemplateRef('rootEl');
+const contentAreaEl = useTemplateRef('contentAreaEl');
+const inputAreaEl = useTemplateRef('inputAreaEl');
 
 /** 値が編集中かどうか */
 const editing = ref<boolean>(false);
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 36dac2954d..2d0135e6a6 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -134,7 +134,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onBeforeUnmount, ref, shallowRef, computed } from 'vue';
+import { nextTick, onBeforeUnmount, ref, useTemplateRef, computed } from 'vue';
 import { version } from '@@/js/config.js';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
@@ -403,7 +403,7 @@ const easterEggEmojis = ref<{
 	emoji: string
 }[]>([]);
 const easterEggEngine = ref<{ stop: () => void } | null>(null);
-const containerEl = shallowRef<HTMLElement>();
+const containerEl = useTemplateRef('containerEl');
 
 function iconLoaded() {
 	const emojis = prefer.s.emojiPalettes[0].emojis;
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index f073a4af7e..819ca2c127 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick, inject } from 'vue';
+import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch, nextTick, inject } from 'vue';
 import tinycolor from 'tinycolor2';
 import { scrollToTop } from '@@/js/scroll.js';
 import { popupMenu } from '@/os.js';
@@ -68,9 +68,9 @@ const emit = defineEmits<{
 
 const pageMetadata = inject(DI.pageMetadata);
 
-const el = shallowRef<HTMLElement>(null);
+const el = useTemplateRef('el');
+const tabHighlightEl = useTemplateRef('tabHighlightEl');
 const tabRefs = {};
-const tabHighlightEl = shallowRef<HTMLElement | null>(null);
 const bg = ref<string | null>(null);
 const height = ref(0);
 const hasTabs = computed(() => {
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
index 5b049e5bad..a56a24ff7d 100644
--- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
@@ -71,8 +71,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
+import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue';
 import { entities } from 'misskey-js';
+import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
@@ -80,7 +81,6 @@ import MkInput from '@/components/MkInput.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import MkSelect from '@/components/MkSelect.vue';
 import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
-import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkDivider from '@/components/MkDivider.vue';
 import * as os from '@/os.js';
@@ -100,7 +100,7 @@ const props = defineProps<{
 
 const { mode, id } = toRefs(props);
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 const loading = ref<number>(0);
 
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index e4119bb62d..08e06ce4b4 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -59,7 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref } from 'vue';
+import { computed, useTemplateRef, ref } from 'vue';
 import XHeader from './_header_.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkPagination from '@/components/MkPagination.vue';
@@ -70,7 +70,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { store } from '@/store.js';
 
-const reports = shallowRef<InstanceType<typeof MkPagination>>();
+const reports = useTemplateRef('reports');
 
 const state = ref('unresolved');
 const reporterOrigin = ref('combined');
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 069ed6e1f3..6e6476b027 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -55,8 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, shallowRef } from 'vue';
+import { computed, ref, useTemplateRef } from 'vue';
 import XHeader from './_header_.vue';
+import type { Paging } from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -66,11 +67,10 @@ import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import type { Paging } from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 const type = ref('all');
 const sort = ref('+createdAt');
diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue
index ae7022a1c9..1fb2c4b726 100644
--- a/packages/frontend/src/pages/admin/modlog.vue
+++ b/packages/frontend/src/pages/admin/modlog.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref } from 'vue';
+import { computed, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XHeader from './_header_.vue';
 import XModLog from './modlog.ModLog.vue';
@@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 
-const logs = shallowRef<InstanceType<typeof MkPagination>>();
+const logs = useTemplateRef('logs');
 
 const type = ref<string | null>(null);
 const moderatorId = ref('');
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 5c50336b2c..5b7f669f6b 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -24,7 +24,7 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 7;
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index 1949201ca0..4c06d94d6d 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import isChromatic from 'chromatic';
@@ -34,8 +34,8 @@ import { initChart } from '@/utility/init-chart.js';
 initChart();
 
 const chartLimit = 50;
-const chartEl = shallowRef<HTMLCanvasElement>();
-const chartEl2 = shallowRef<HTMLCanvasElement>();
+const chartEl = useTemplateRef('chartEl');
+const chartEl2 = useTemplateRef('chartEl2');
 const fetching = ref(true);
 
 const { handler: externalTooltipHandler } = useChartTooltip();
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 424bcdd51f..32dd981ca9 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { initChart } from '@/utility/init-chart.js';
@@ -26,7 +26,7 @@ const props = defineProps<{
 	data: InstanceForPie[];
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip({
 	position: 'middle',
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 34c0945ddb..6fc941a848 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -22,7 +22,7 @@ const props = defineProps<{
 	type: string;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue
index de6b254412..cf07cddced 100644
--- a/packages/frontend/src/pages/admin/overview.queue.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XChart from './overview.queue.chart.vue';
 import type { ApQueueDomain } from '@/pages/admin/queue.vue';
@@ -48,10 +48,10 @@ const activeSincePrevTick = ref(0);
 const active = ref(0);
 const delayed = ref(0);
 const waiting = ref(0);
-const chartProcess = shallowRef<InstanceType<typeof XChart>>();
-const chartActive = shallowRef<InstanceType<typeof XChart>>();
-const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
-const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
+const chartProcess = useTemplateRef('chartProcess');
+const chartActive = useTemplateRef('chartActive');
+const chartDelayed = useTemplateRef('chartDelayed');
+const chartWaiting = useTemplateRef('chartWaiting');
 
 const props = defineProps<{
 	domain: ApQueueDomain;
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
index c23662572a..616815a6a6 100644
--- a/packages/frontend/src/pages/admin/overview.vue
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed } from 'vue';
+import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XFederation from './overview.federation.vue';
 import XInstances from './overview.instances.vue';
@@ -85,7 +85,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null);
 const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
 const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index 9c7a83b1fb..5dd2887024 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -22,7 +22,7 @@ const props = defineProps<{
 	type: string;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
index 607a974d20..1ba02d6e0e 100644
--- a/packages/frontend/src/pages/admin/queue.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XChart from './queue.chart.chart.vue';
 import type { ApQueueDomain } from '@/pages/admin/queue.vue';
@@ -65,10 +65,10 @@ const active = ref(0);
 const delayed = ref(0);
 const waiting = ref(0);
 const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]);
-const chartProcess = shallowRef<InstanceType<typeof XChart>>();
-const chartActive = shallowRef<InstanceType<typeof XChart>>();
-const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
-const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
+const chartProcess = useTemplateRef('chartProcess');
+const chartActive = useTemplateRef('chartActive');
+const chartDelayed = useTemplateRef('chartDelayed');
+const chartWaiting = useTemplateRef('chartWaiting');
 
 const props = defineProps<{
 	domain: ApQueueDomain;
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 1998144c05..a44951a947 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref, watchEffect } from 'vue';
+import { computed, useTemplateRef, ref, watchEffect } from 'vue';
 import XHeader from './_header_.vue';
 import { defaultMemoryStorage } from '@/memory-storage';
 import MkButton from '@/components/MkButton.vue';
@@ -82,7 +82,7 @@ type SearchQuery = {
 	hostname?: string;
 };
 
-const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const paginationComponent = useTemplateRef('paginationComponent');
 const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
 
 const sort = ref(storedQuery.sort ?? '+createdAt');
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index f0587a5ca0..5f98ed798f 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
+import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
@@ -42,8 +42,8 @@ const props = defineProps<{
 
 const antenna = ref<Misskey.entities.Antenna | null>(null);
 const queue = ref(0);
-const rootEl = shallowRef<HTMLElement>();
-const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
+const rootEl = useTemplateRef('rootEl');
+const tlEl = useTemplateRef('tlEl');
 
 function queueUpdated(q) {
 	queue.value = q;
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 46e7a3d05e..c714ccb003 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -72,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
+import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkPagination from '@/components/MkPagination.vue';
@@ -86,7 +86,7 @@ import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 
-const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent');
 
 const tab = ref('local');
 const query = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index e30e381ce1..baae888a94 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -191,7 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch, useTemplateRef } from 'vue';
 import * as Matter from 'matter-js';
 import * as Misskey from 'misskey-js';
 import { DropAndFusionGame } from 'misskey-bubble-game';
@@ -567,8 +567,8 @@ let game = new DropAndFusionGame({
 });
 attachGameEvents();
 
-const containerEl = shallowRef<HTMLElement>();
-const canvasEl = shallowRef<HTMLCanvasElement>();
+const containerEl = useTemplateRef('containerEl');
+const canvasEl = useTemplateRef('canvasEl');
 const dropperX = ref(0);
 const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
 const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index c112045a21..c0618b9fce 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref, shallowRef, computed } from 'vue';
+import { watch, ref, useTemplateRef, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkUserList from '@/components/MkUserList.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
@@ -77,7 +77,7 @@ const props = defineProps<{
 }>();
 
 const origin = ref('local');
-const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>();
+const tagsEl = useTemplateRef('tagsEl');
 const tagsLocal = ref<Misskey.entities.Hashtag[]>([]);
 const tagsRemote = ref<Misskey.entities.Hashtag[]>([]);
 
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index d24ebe6aae..b76f6a6033 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import XFeatured from './explore.featured.vue';
 import XUsers from './explore.users.vue';
 import XRoles from './explore.roles.vue';
@@ -38,7 +38,7 @@ const props = withDefaults(defineProps<{
 });
 
 const tab = ref(props.initialTab);
-const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>();
+const tagsEl = useTemplateRef('tagsEl');
 
 watch(() => props.tag, () => {
 	if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null);
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index f44d2364af..1ea501c34c 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef, computed, ref } from 'vue';
+import { useTemplateRef, computed, ref } from 'vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
@@ -57,7 +57,7 @@ import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/i.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
-const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const paginationComponent = useTemplateRef('paginationComponent');
 
 const pagination = computed<Paging>(() => tab.value === 'list' ? {
 	endpoint: 'following/requests/list',
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index a6f0dfcf4b..9181e7f2ef 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, shallowRef } from 'vue';
+import { computed, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { Paging } from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
@@ -47,7 +47,7 @@ import { definePage } from '@/page.js';
 import { serverErrorImageUrl, instance } from '@/instance.js';
 import { $i } from '@/i.js';
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 const currentInviteLimit = ref<null | number>(null);
 const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
 const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index 247720d9ac..d354221ca3 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref, shallowRef, computed } from 'vue';
+import { watch, ref, useTemplateRef, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
@@ -46,7 +46,7 @@ const tab = ref('my');
 
 const favorites = ref<Misskey.entities.Clip[] | null>(null);
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 watch(tab, async () => {
 	favorites.value = await misskeyApi('clips/my-favorites');
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
index a8b4da2a06..4a980ce472 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-
-import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
+import { watch, ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import XContainer from '../page-editor.container.vue';
 import { i18n } from '@/i18n.js';
@@ -33,7 +32,7 @@ const emit = defineEmits<{
 let autocomplete: Autocomplete;
 
 const text = ref(props.modelValue.text ?? '');
-const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
+const inputEl = useTemplateRef('inputEl');
 
 watch(text, () => {
 	emit('update:modelValue', {
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index c88f7fbe15..2a07a9a21c 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -106,7 +106,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
@@ -132,7 +132,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const page = ref(0);
 const token = ref<string | number | null>(null);
 const backupCodes = ref<string[]>();
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
index 335ecedfa8..0f02d95d71 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref, computed } from 'vue';
+import { useTemplateRef, ref, computed } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
@@ -82,7 +82,7 @@ const emit = defineEmits<{
 	(ev: 'detach'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
 const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id)));
 const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 89dc9581c2..c315e9289a 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, onActivated, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import type { PageMetadata } from '@/page.js';
 import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import { i18n } from '@/i18n.js';
@@ -56,7 +56,7 @@ const indexInfo = {
 	hideHeader: true,
 };
 const INFO = ref<PageMetadata>(indexInfo);
-const el = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
 const childInfo = ref<null | PageMetadata>(null);
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index f059050621..530b63b701 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -66,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, computed } from 'vue';
+import { useTemplateRef, computed } from 'vue';
 import { notificationTypes } from '@@/js/const.js';
 import XNotificationConfig from './notifications.notification-config.vue';
 import type { NotificationConfig } from './notifications.notification-config.vue';
@@ -88,7 +88,7 @@ const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequest
 
 const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken'] satisfies (typeof notificationTypes[number])[] as string[];
 
-const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
+const allowButton = useTemplateRef('allowButton');
 const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer);
 const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false);
 const userLists = await misskeyApi('users/lists/list');
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 68f4b7a26d..38497e7c1b 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue';
+import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } from 'vue';
 import { scroll } from '@@/js/scroll.js';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -59,8 +59,8 @@ import { prefer } from '@/preferences.js';
 
 provide('shouldOmitHeaderTitle', true);
 
-const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
-const rootEl = shallowRef<HTMLElement>();
+const tlComponent = useTemplateRef('tlComponent');
+const rootEl = useTemplateRef('rootEl');
 
 type TimelinePageSrc = BasicTimelineType | `list:${string}`;
 
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index e4857c7d30..be84fd6efe 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
+import MkTimeline from '@/components/MkTimeline.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
@@ -41,8 +41,8 @@ const props = defineProps<{
 
 const list = ref<Misskey.entities.UserList | null>(null);
 const queue = ref(0);
-const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
-const rootEl = shallowRef<HTMLElement>();
+const tlEl = useTemplateRef('tlEl');
+const rootEl = useTemplateRef('rootEl');
 
 watch(() => props.listId, async () => {
 	list.value = await misskeyApi('users/lists/show', {
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index 3b5e9617d5..f5d2002669 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index af8e4d43a6..01c62810d4 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 50;
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index 32e4e78d21..ed12b1b5c7 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue
index 8fb84fd58f..680fe08c14 100644
--- a/packages/frontend/src/pages/welcome.timeline.note.vue
+++ b/packages/frontend/src/pages/welcome.timeline.note.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, onUpdated, onMounted } from 'vue';
+import { ref, useTemplateRef, onUpdated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkMediaList from '@/components/MkMediaList.vue';
@@ -45,7 +45,7 @@ defineProps<{
 	note: Misskey.entities.Note;
 }>();
 
-const noteTextEl = shallowRef<HTMLDivElement>();
+const noteTextEl = useTemplateRef('noteTextEl');
 const shouldCollapse = ref(false);
 const showContent = ref(false);
 
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 180b2e5f77..6108a8a698 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -21,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { onUpdated, ref, shallowRef } from 'vue';
+import { onUpdated, ref, useTemplateRef } from 'vue';
+import { getScrollContainer } from '@@/js/scroll.js';
 import XNote from '@/pages/welcome.timeline.note.vue';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
-import { getScrollContainer } from '@@/js/scroll.js';
 
 const notes = ref<Misskey.entities.Note[]>([]);
 const isScrolling = ref(false);
 const scrollState = ref<null | 'intro' | 'loop'>(null);
-const notesMainContainerEl = shallowRef<HTMLElement>();
+const notesMainContainerEl = useTemplateRef('notesMainContainerEl');
 
 misskeyApiGet('notes/featured').then(_notes => {
 	notes.value = _notes;
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 8200b6ad38..43d24da28b 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, computed, watch, ref, shallowRef } from 'vue';
+import { defineAsyncComponent, computed, watch, ref, useTemplateRef } from 'vue';
 import { openInstanceMenu } from './_common_/common.js';
 // import { host } from '@@/js/config.js';
 import * as os from '@/os.js';
@@ -76,7 +76,7 @@ const otherNavItemIndicated = computed<boolean>(() => {
 	}
 	return false;
 });
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 // let accounts = $ref([]);
 // let connection = $ref(null);
 const iconOnly = ref(false);
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 2ef06726f9..c17e78bb03 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef } from 'vue';
+import { defineAsyncComponent, onMounted, provide, ref, computed, useTemplateRef } from 'vue';
 import { instanceName } from '@@/js/config.js';
 import { isLink } from '@@/js/is-link.js';
 import XSidebar from './classic.sidebar.vue';
@@ -77,7 +77,7 @@ const fullView = ref(false);
 const globalHeaderHeight = ref(0);
 const wallpaper = miLocalStorage.getItem('wallpaper') != null;
 const showMenuOnTop = computed(() => store.s.menuDisplay === 'top');
-const live2d = shallowRef<HTMLIFrameElement>();
+const live2d = useTemplateRef('live2d');
 const widgetsLeft = ref<HTMLElement>();
 const widgetsRight = ref<HTMLElement>();
 
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 4e9ec7c586..4c33d7cb27 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -92,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
+import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue';
 import { v4 as uuid } from 'uuid';
 import XCommon from './_common_/common.vue';
 import XSidebar from '@/ui/_common_/navbar.vue';
@@ -169,7 +169,7 @@ function showSettings() {
 	os.pageWindow('/settings/deck');
 }
 
-const columnsEl = shallowRef<HTMLElement>();
+const columnsEl = useTemplateRef('columnsEl');
 
 const addColumn = async (ev) => {
 	const { canceled, result: column } = await os.select({
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index ae282ba324..194b56c842 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue';
+import { onMounted, ref, useTemplateRef, watch, defineAsyncComponent } from 'vue';
 import XColumn from './column.vue';
 import type { entities as MisskeyEntities } from 'misskey-js';
 import type { Column } from '@/deck.js';
@@ -34,7 +34,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const antennaName = ref<string | null>(null);
 
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 964b42874e..c2644da707 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, shallowRef, watch, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
@@ -40,7 +40,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const channel = shallowRef<Misskey.entities.Channel>();
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index 3358b4e52b..4841a78323 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed } from 'vue';
+import { onBeforeUnmount, onMounted, provide, watch, useTemplateRef, ref, computed } from 'vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
 import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js';
@@ -68,7 +68,7 @@ const emit = defineEmits<{
 	(ev: 'headerWheel', ctx: WheelEvent): void;
 }>();
 
-const body = shallowRef<HTMLDivElement | null>();
+const body = useTemplateRef('body');
 
 const dragging = ref(false);
 watch(dragging, v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 2b4e86b8a2..a8f17feb23 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, shallowRef, ref, onMounted } from 'vue';
+import { watch, useTemplateRef, ref, onMounted } from 'vue';
 import XColumn from './column.vue';
 import type { entities as MisskeyEntities } from 'misskey-js';
 import type { Column } from '@/deck.js';
@@ -34,7 +34,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const withRenotes = ref(props.column.withRenotes ?? true);
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const listName = ref<string | null>(null);
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
index 0a2c0e9952..8378dddfef 100644
--- a/packages/frontend/src/ui/deck/notifications-column.vue
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -12,10 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, shallowRef } from 'vue';
+import { defineAsyncComponent, useTemplateRef } from 'vue';
 import XColumn from './column.vue';
-import { updateColumn } from '@/deck.js';
 import type { Column } from '@/deck.js';
+import { updateColumn } from '@/deck.js';
 import XNotifications from '@/components/MkNotifications.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
@@ -25,7 +25,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const notificationsComponent = shallowRef<InstanceType<typeof XNotifications>>();
+const notificationsComponent = useTemplateRef('notificationsComponent');
 
 function func() {
 	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 5d07440017..468b3e49e0 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
@@ -32,7 +32,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const roleName = ref<string | null>(null);
 
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index 2dec7bf5aa..6759135654 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, watch, ref, shallowRef, computed } from 'vue';
+import { onMounted, watch, ref, useTemplateRef, computed } from 'vue';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
@@ -50,7 +50,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const withRenotes = ref(props.column.withRenotes ?? true);
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 133360972b..d85745bde6 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -95,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, watch, shallowRef } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, watch, useTemplateRef } from 'vue';
 import { instanceName } from '@@/js/config.js';
 import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
 import { isLink } from '@@/js/is-link.js';
@@ -135,7 +135,7 @@ window.addEventListener('resize', () => {
 
 const pageMetadata = ref<null | PageMetadata>(null);
 const widgetsShowing = ref(false);
-const navFooter = shallowRef<HTMLElement>();
+const navFooter = useTemplateRef('navFooter');
 
 provide(DI.router, mainRouter);
 provideMetadataReceiver((metadataGetter) => {
diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue
index 29e21ee6c3..2bc7facc88 100644
--- a/packages/frontend/src/widgets/WidgetAichan.vue
+++ b/packages/frontend/src/widgets/WidgetAichan.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
@@ -35,7 +35,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
-const live2d = shallowRef<HTMLIFrameElement>();
+const live2d = useTemplateRef('live2d');
 
 const touched = () => {
 	//if (this.live2d) this.live2d.changeExpression('gurugurume');
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index c6724127fa..0c9f98f9e3 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud">
 	<div class="">
-		<MkTagCloud v-if="activeInstances">
+		<MkTagCloud v-if="activeInstances" ref="cloud">
 			<li v-for="instance in activeInstances" :key="instance.id">
 				<a @click.prevent="onInstanceClick(instance)">
 					<img style="width: 32px;" :src="getInstanceIcon(instance)">
@@ -18,8 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { shallowRef, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
@@ -27,7 +28,6 @@ import MkContainer from '@/components/MkContainer.vue';
 import MkTagCloud from '@/components/MkTagCloud.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const name = 'instanceCloud';
@@ -50,7 +50,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
-const cloud = shallowRef<InstanceType<typeof MkTagCloud> | null>();
+const cloud = useTemplateRef('cloud');
 const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null);
 
 function onInstanceClick(i) {
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 2cbf3a8ef6..2ccbb7a28f 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -17,14 +17,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef } from 'vue';
+import { onMounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'slideshow';
@@ -54,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
 
 const images = ref<Misskey.entities.DriveFile[]>([]);
 const fetching = ref(true);
-const slideA = shallowRef<HTMLElement>();
-const slideB = shallowRef<HTMLElement>();
+const slideA = useTemplateRef('slideA');
+const slideB = useTemplateRef('slideB');
 
 const change = () => {
 	if (images.value.length === 0) return;
-- 
cgit v1.2.3-freya

