diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml
index 6cd8bf60d5204d737333e01f4cf129052c4db47e..2579beb53a8ef6213e5495121f886d4e5c1b7d82 100644
--- a/.github/workflows/check-spdx-license-id.yml
+++ b/.github/workflows/check-spdx-license-id.yml
@@ -48,12 +48,14 @@ jobs:
             "packages/backend/migration"
             "packages/backend/src"
             "packages/backend/test"
+            "packages/frontend-shared/src"
             "packages/frontend/.storybook"
             "packages/frontend/@types"
             "packages/frontend/lib"
             "packages/frontend/public"
             "packages/frontend/src"
             "packages/frontend/test"
+            "packages/frontend-embed/src"
             "packages/misskey-bubble-game/src"
             "packages/misskey-reversi/src"
             "packages/sw/src"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 222a14d28df7b221375f7fab0e057c2844dbe4d0..11903e3ec2480d65f0357f5eb183bd16debaf64f 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -8,6 +8,8 @@ on:
     paths:
       - packages/backend/**
       - packages/frontend/**
+      - packages/frontend-shared/**
+      - packages/frontend-embed/**
       - packages/sw/**
       - packages/misskey-js/**
       - packages/shared/eslint.config.js
@@ -16,6 +18,8 @@ on:
     paths:
       - packages/backend/**
       - packages/frontend/**
+      - packages/frontend-shared/**
+      - packages/frontend-embed/**
       - packages/sw/**
       - packages/misskey-js/**
       - packages/shared/eslint.config.js
@@ -45,6 +49,8 @@ jobs:
         workspace:
         - backend
         - frontend
+        - frontend-shared
+        - frontend-embed
         - sw
         - misskey-js
     env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 398134436babb27cae64024f10400f44c6fa6616..16c6eb674d3d814649149d14866d02e9426a6879 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 -
 
 ### Client
+- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
+  - 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です
 - サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
 - Enhance: アイコンデコレーション管理画面にプレビューを追加
 - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
diff --git a/Dockerfile b/Dockerfile
index e247bbcd775f42ec540fef0a5322e503f9560d06..e21b2a31fcff14ed07f2c247ea647e682cc09102 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,9 @@ WORKDIR /misskey
 COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"]
 COPY --link ["scripts", "./scripts"]
 COPY --link ["packages/backend/package.json", "./packages/backend/"]
+COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"]
 COPY --link ["packages/frontend/package.json", "./packages/frontend/"]
+COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"]
 COPY --link ["packages/sw/package.json", "./packages/sw/"]
 COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
 COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 9fd3441ab1c08f2272b213fdb7dc62fa07db11c0..fecc5703950debf0fb79acc0bea567fd8889f213 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5068,6 +5068,18 @@ export interface Locale extends ILocale {
      * 作成したアンテナ
      */
     "createdAntennas": string;
+    /**
+     * {x}から
+     */
+    "fromX": ParameterizedString<"x">;
+    /**
+     * 埋め込みコードを生成
+     */
+    "genEmbedCode": string;
+    /**
+     * このユーザーのノート一覧
+     */
+    "noteOfThisUser": string;
     /**
      * これ以上このクリップにノートを追加できません。
      */
@@ -10196,6 +10208,60 @@ export interface Locale extends ILocale {
          */
         "native": string;
     };
+    "_embedCodeGen": {
+        /**
+         * 埋め込みコードをカスタマイズ
+         */
+        "title": string;
+        /**
+         * ヘッダーを表示
+         */
+        "header": string;
+        /**
+         * 自動で続きを読み込む(非推奨)
+         */
+        "autoload": string;
+        /**
+         * 高さの最大値
+         */
+        "maxHeight": string;
+        /**
+         * 0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。
+         */
+        "maxHeightDescription": string;
+        /**
+         * 高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。
+         */
+        "maxHeightWarn": string;
+        /**
+         * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。
+         */
+        "previewIsNotActual": string;
+        /**
+         * 角丸にする
+         */
+        "rounded": string;
+        /**
+         * 外枠に枠線をつける
+         */
+        "border": string;
+        /**
+         * プレビューに反映
+         */
+        "applyToPreview": string;
+        /**
+         * 埋め込みコードを作成
+         */
+        "generateCode": string;
+        /**
+         * コードが生成されました
+         */
+        "codeGenerated": string;
+        /**
+         * 生成されたコードをウェブサイトに貼り付けてご利用ください。
+         */
+        "codeGeneratedDescription": string;
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 587b67d98765c564732632cb0e364dd87e6546ca..a1210bad29481f871f0032c90c0dd74eefae8be9 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1263,6 +1263,9 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示
 sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
 createdLists: "作成したリスト"
 createdAntennas: "作成したアンテナ"
+fromX: "{x}から"
+genEmbedCode: "埋め込みコードを生成"
+noteOfThisUser: "このユーザーのノート一覧"
 clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
 
 _delivery:
@@ -2718,3 +2721,18 @@ _contextMenu:
   app: "アプリケーション"
   appWithShift: "Shiftキーでアプリケーション"
   native: "ブラウザのUI"
+
+_embedCodeGen:
+  title: "埋め込みコードをカスタマイズ"
+  header: "ヘッダーを表示"
+  autoload: "自動で続きを読み込む(非推奨)"
+  maxHeight: "高さの最大値"
+  maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。"
+  maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。"
+  previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。"
+  rounded: "角丸にする"
+  border: "外枠に枠線をつける"
+  applyToPreview: "プレビューに反映"
+  generateCode: "埋め込みコードを作成"
+  codeGenerated: "コードが生成されました"
+  codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。"
diff --git a/package.json b/package.json
index 310ea982147383d79a8931af9cab5b2c132ef94d..f6507acdb2403c5fe3e81a1dae38b7bda048555c 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,9 @@
 	},
 	"packageManager": "pnpm@9.6.0",
 	"workspaces": [
+		"packages/frontend-shared",
 		"packages/frontend",
+		"packages/frontend-embed",
 		"packages/backend",
 		"packages/sw",
 		"packages/misskey-js",
diff --git a/packages/backend/assets/embed.js b/packages/backend/assets/embed.js
new file mode 100644
index 0000000000000000000000000000000000000000..24fccc1b6c1f4554e3c25e7327a7ee49e50d16db
--- /dev/null
+++ b/packages/backend/assets/embed.js
@@ -0,0 +1,31 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: MIT
+ */
+//@ts-check
+(() => {
+	/** @type {NodeListOf<HTMLIFrameElement>} */
+	const els = document.querySelectorAll('iframe[data-misskey-embed-id]');
+
+	window.addEventListener('message', function (event) {
+		els.forEach((el) => {
+			if (event.source !== el.contentWindow) {
+				return;
+			}
+
+			const id = el.dataset.misskeyEmbedId;
+
+			if (event.data.type === 'misskey:embed:ready') {
+				el.contentWindow?.postMessage({
+					type: 'misskey:embedParent:registerIframeId',
+					payload: {
+						iframeId: id,
+					}
+				}, '*');
+			}
+			if (event.data.type === 'misskey:embed:changeHeight' && event.data.iframeId === id) {
+				el.style.height = event.data.payload.height + 'px';
+			}
+		});
+	});
+})();
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index cff0194780cb50946ca004f81503efb6cd3ca98b..cbd6d1c086dcfe2e61d5ff9cb7279995d75f21df 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -160,8 +160,10 @@ export type Config = {
 	authUrl: string;
 	driveUrl: string;
 	userAgent: string;
-	clientEntry: string;
-	clientManifestExists: boolean;
+	frontendEntry: string;
+	frontendManifestExists: boolean;
+	frontendEmbedEntry: string;
+	frontendEmbedManifestExists: boolean;
 	mediaProxy: string;
 	externalMediaProxyEnabled: boolean;
 	videoThumbnailGenerator: string | null;
@@ -196,10 +198,16 @@ const path = process.env.MISSKEY_CONFIG_YML
 
 export function loadConfig(): Config {
 	const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
-	const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
-	const clientManifest = clientManifestExists ?
-		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
+
+	const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
+	const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
+	const frontendManifest = frontendManifestExists ?
+		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
 		: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
+	const frontendEmbedManifest = frontendEmbedManifestExists ?
+		JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
+		: { 'src/boot.ts': { file: 'src/boot.ts' } };
+
 	const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
 
 	const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
@@ -270,8 +278,10 @@ export function loadConfig(): Config {
 			config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
 			: null,
 		userAgent: `Misskey/${version} (${config.url})`,
-		clientEntry: clientManifest['src/_boot_.ts'],
-		clientManifestExists: clientManifestExists,
+		frontendEntry: frontendManifest['src/_boot_.ts'],
+		frontendManifestExists: frontendManifestExists,
+		frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'],
+		frontendEmbedManifestExists: frontendEmbedManifestExists,
 		perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
 		perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500,
 		deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index f55790b6363be11bb65d0a09c57b3dc8759989ef..5e0ec390f2e838edfbc6680997583b3c830ecdd4 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -61,7 +61,8 @@ const staticAssets = `${_dirname}/../../../assets/`;
 const clientAssets = `${_dirname}/../../../../frontend/assets/`;
 const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
 const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
-const viteOut = `${_dirname}/../../../../../built/_vite_/`;
+const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`;
+const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`;
 const tarball = `${_dirname}/../../../../../built/tarball/`;
 
 @Injectable()
@@ -277,15 +278,22 @@ export class ClientServerService {
 		});
 
 		//#region vite assets
-		if (this.config.clientManifestExists) {
+		if (this.config.frontendEmbedManifestExists) {
 			fastify.register((fastify, options, done) => {
 				fastify.register(fastifyStatic, {
-					root: viteOut,
+					root: frontendViteOut,
 					prefix: '/vite/',
 					maxAge: ms('30 days'),
 					immutable: true,
 					decorateReply: false,
 				});
+				fastify.register(fastifyStatic, {
+					root: frontendEmbedViteOut,
+					prefix: '/embed_vite/',
+					maxAge: ms('30 days'),
+					immutable: true,
+					decorateReply: false,
+				});
 				fastify.addHook('onRequest', handleRequestRedirectToOmitSearch);
 				done();
 			});
@@ -296,6 +304,13 @@ export class ClientServerService {
 				prefix: '/vite',
 				rewritePrefix: '/vite',
 			});
+
+			const embedPort = (process.env.EMBED_VITE_PORT ?? '5174');
+			fastify.register(fastifyProxy, {
+				upstream: 'http://localhost:' + embedPort,
+				prefix: '/embed_vite',
+				rewritePrefix: '/embed_vite',
+			});
 		}
 		//#endregion
 
@@ -425,6 +440,13 @@ export class ClientServerService {
 		// Manifest
 		fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply));
 
+		// Embed Javascript
+		fastify.get('/embed.js', async (request, reply) => {
+			return await reply.sendFile('/embed.js', staticAssets, {
+				maxAge: ms('1 day'),
+			});
+		});
+
 		fastify.get('/robots.txt', async (request, reply) => {
 			return await reply.sendFile('/robots.txt', staticAssets);
 		});
@@ -762,7 +784,7 @@ export class ClientServerService {
 		});
 		//#endregion
 
-		//region noindex pages
+		//#region noindex pages
 		// Tags
 		fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => {
 			return await renderBase(reply, { noindex: true });
@@ -772,7 +794,20 @@ export class ClientServerService {
 		fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => {
 			return await renderBase(reply, { noindex: true });
 		});
-		//endregion
+		//#endregion
+
+		//#region embed pages
+		fastify.get('/embed/*', async (request, reply) => {
+			const meta = await this.metaService.fetch();
+
+			reply.removeHeader('X-Frame-Options');
+
+			reply.header('Cache-Control', 'public, max-age=3600');
+			return await reply.view('base-embed', {
+				title: meta.name ?? 'Misskey',
+				...await this.generateCommonPugData(meta),
+			});
+		});
 
 		fastify.get('/_info_card_', async (request, reply) => {
 			const meta = await this.metaService.fetch(true);
@@ -787,6 +822,7 @@ export class ClientServerService {
 				originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
 			});
 		});
+		//#endregion
 
 		fastify.get('/bios', async (request, reply) => {
 			return await reply.view('bios', {
diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js
new file mode 100644
index 0000000000000000000000000000000000000000..48d1cd262bdce8cfebd665d5d0fc8e943eefd109
--- /dev/null
+++ b/packages/backend/src/server/web/boot.embed.js
@@ -0,0 +1,219 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+'use strict';
+
+// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
+(async () => {
+	window.onerror = (e) => {
+		console.error(e);
+		renderError('SOMETHING_HAPPENED');
+	};
+	window.onunhandledrejection = (e) => {
+		console.error(e);
+		renderError('SOMETHING_HAPPENED_IN_PROMISE');
+	};
+
+	let forceError = localStorage.getItem('forceError');
+	if (forceError != null) {
+		renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
+		return;
+	}
+
+	// パラメータに応じてsplashのスタイルを変更
+	const params = new URLSearchParams(location.search);
+	if (params.has('rounded') && params.get('rounded') === 'false') {
+		document.documentElement.classList.add('norounded');
+	}
+	if (params.has('border') && params.get('border') === 'false') {
+		document.documentElement.classList.add('noborder');
+	}
+
+	//#region Detect language & fetch translations
+	if (!localStorage.hasOwnProperty('locale')) {
+		const supportedLangs = LANGS;
+		let lang = localStorage.getItem('lang');
+		if (lang == null || !supportedLangs.includes(lang)) {
+			if (supportedLangs.includes(navigator.language)) {
+				lang = navigator.language;
+			} else {
+				lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
+
+				// Fallback
+				if (lang == null) lang = 'en-US';
+			}
+		}
+
+		const metaRes = await window.fetch('/api/meta', {
+			method: 'POST',
+			body: JSON.stringify({}),
+			credentials: 'omit',
+			cache: 'no-cache',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+		});
+		if (metaRes.status !== 200) {
+			renderError('META_FETCH');
+			return;
+		}
+		const meta = await metaRes.json();
+		const v = meta.version;
+		if (v == null) {
+			renderError('META_FETCH_V');
+			return;
+		}
+
+		// for https://github.com/misskey-dev/misskey/issues/10202
+		if (lang == null || lang.toString == null || lang.toString() === 'null') {
+			console.error('invalid lang value detected!!!', typeof lang, lang);
+			lang = 'en-US';
+		}
+
+		const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
+		if (localRes.status === 200) {
+			localStorage.setItem('lang', lang);
+			localStorage.setItem('locale', await localRes.text());
+			localStorage.setItem('localeVersion', v);
+		} else {
+			renderError('LOCALE_FETCH');
+			return;
+		}
+	}
+	//#endregion
+
+	//#region Script
+	async function importAppScript() {
+		await import(`/embed_vite/${CLIENT_ENTRY}`)
+			.catch(async e => {
+				console.error(e);
+				renderError('APP_IMPORT');
+			});
+	}
+
+	// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
+	if (document.readyState !== 'loading') {
+		importAppScript();
+	} else {
+		window.addEventListener('DOMContentLoaded', () => {
+			importAppScript();
+		});
+	}
+	//#endregion
+
+	async function addStyle(styleText) {
+		let css = document.createElement('style');
+		css.appendChild(document.createTextNode(styleText));
+		document.head.appendChild(css);
+	}
+
+	async function renderError(code) {
+		// Cannot set property 'innerHTML' of null を回避
+		if (document.readyState === 'loading') {
+			await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
+		}
+		document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
+		<div class="message">読み込みに失敗しました</div>
+		<div class="submessage">Failed to initialize Misskey</div>
+		<div class="submessage">Error Code: ${code}</div>
+		<button onclick="location.reload(!0)">
+			<div>リロード</div>
+			<div><small>Reload</small></div>
+		</button>`;
+		addStyle(`
+		#misskey_app,
+		#splash {
+			display: none !important;
+		}
+
+		html,
+		body {
+			margin: 0;
+		}
+
+		body {
+			position: relative;
+			color: #dee7e4;
+			font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+			line-height: 1.35;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			justify-content: center;
+			min-height: 100vh;
+			margin: 0;
+			padding: 24px;
+			box-sizing: border-box;
+			overflow: hidden;
+
+			border-radius: var(--radius, 12px);
+			border: 1px solid rgba(231, 255, 251, 0.14);
+		}
+
+		body::before {
+			content: '';
+			position: fixed;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: #192320;
+			border-radius: var(--radius, 12px);
+			z-index: -1;
+		}
+
+		html.embed.norounded body,
+		html.embed.norounded body::before {
+			border-radius: 0;
+		}
+
+		html.embed.noborder body {
+			border: none;
+		}
+
+		.icon {
+			max-width: 60px;
+			width: 100%;
+			height: auto;
+			margin-bottom: 20px;
+			color: #dec340;
+		}
+
+		.message {
+			text-align: center;
+			font-size: 20px;
+			font-weight: 700;
+			margin-bottom: 20px;
+		}
+
+		.submessage {
+			text-align: center;
+			font-size: 90%;
+			margin-bottom: 7.5px;
+		}
+
+		.submessage:last-of-type {
+			margin-bottom: 20px;
+		}
+
+		button {
+			padding: 7px 14px;
+			min-width: 100px;
+			font-weight: 700;
+			font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+			line-height: 1.35;
+			border-radius: 99rem;
+			background-color: #b4e900;
+			color: #192320;
+			border: none;
+			cursor: pointer;
+			-webkit-tap-highlight-color: transparent;
+		}
+
+		button:hover {
+			background-color: #c6ff03;
+		}`);
+	}
+})();
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index 5283596316ea72af01d911fc8ea6e2cfd664f802..7c6a5334291da2cfca3d5602d104b64a4db509ef 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -3,17 +3,6 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-/**
- * BOOT LOADER
- * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。
- * - 翻訳ファイルをフェッチする。
- * - バージョンに基づいて適切なメインスクリプトを読み込む。
- * - キャッシュされたコンパイル済みテーマを適用する。
- * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。
- * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。
- * 注: webpackは介さないため、このファイルではrequireやimportは使えません。
- */
-
 'use strict';
 
 // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css
index e4723c24fd2dd751b4b13351fc7fbd5c1bb8860a..dbcc8f537cdd66b3b01605f3469a69a047ba0ffd 100644
--- a/packages/backend/src/server/web/style.css
+++ b/packages/backend/src/server/web/style.css
@@ -47,6 +47,7 @@ html {
 	transform: translateY(70px);
 	color: var(--accent);
 }
+
 #splashSpinner > .spinner {
 	position: absolute;
 	top: 0;
diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css
new file mode 100644
index 0000000000000000000000000000000000000000..a7b110d80a1098e0b2db61174bccd85fe4e1f6a6
--- /dev/null
+++ b/packages/backend/src/server/web/style.embed.css
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+html {
+	background-color: var(--bg);
+	color: var(--fg);
+}
+
+html.embed {
+	box-sizing: border-box;
+	background-color: transparent;
+	color-scheme: light dark;
+	max-width: 500px;
+}
+
+#splash {
+	position: fixed;
+	z-index: 10000;
+	top: 0;
+	left: 0;
+	width: 100vw;
+	height: 100vh;
+	cursor: wait;
+	background-color: var(--bg);
+	opacity: 1;
+	transition: opacity 0.5s ease;
+}
+
+html.embed #splash {
+	box-sizing: border-box;
+	min-height: 300px;
+	border-radius: var(--radius, 12px);
+	border: 1px solid var(--divider, #e8e8e8);
+}
+
+html.embed.norounded #splash {
+	border-radius: 0;
+}
+
+html.embed.noborder #splash {
+	border: none;
+}
+
+#splashIcon {
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	margin: auto;
+	width: 64px;
+	height: 64px;
+	pointer-events: none;
+}
+
+#splashSpinner {
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	margin: auto;
+	display: inline-block;
+	width: 28px;
+	height: 28px;
+	transform: translateY(70px);
+	color: var(--accent);
+}
+
+#splashSpinner > .spinner {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 28px;
+	height: 28px;
+	fill-rule: evenodd;
+	clip-rule: evenodd;
+	stroke-linecap: round;
+	stroke-linejoin: round;
+	stroke-miterlimit: 1.5;
+}
+#splashSpinner > .spinner.bg {
+	opacity: 0.275;
+}
+#splashSpinner > .spinner.fg {
+	animation: splashSpinner 0.5s linear infinite;
+}
+
+@keyframes splashSpinner {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug
new file mode 100644
index 0000000000000000000000000000000000000000..d773f2676a59db34a5010a64845a5e35e52e7591
--- /dev/null
+++ b/packages/backend/src/server/web/views/base-embed.pug
@@ -0,0 +1,67 @@
+block vars
+
+block loadClientEntry
+	- const entry = config.frontendEmbedEntry;
+
+doctype html
+
+html(class='embed')
+
+	head
+		meta(charset='utf-8')
+		meta(name='application-name' content='Misskey')
+		meta(name='referrer' content='origin')
+		meta(name='theme-color' content= themeColor || '#86b300')
+		meta(name='theme-color-orig' content= themeColor || '#86b300')
+		meta(name='viewport' content='width=device-width, initial-scale=1')
+		meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
+		link(rel='icon' href= icon || '/favicon.ico')
+		link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
+		link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
+
+		if !config.frontendEmbedManifestExists
+				script(type="module" src="/embed_vite/@vite/client")
+
+		if Array.isArray(entry.css)
+			each href in entry.css
+				link(rel='stylesheet' href=`/embed_vite/${href}`)
+
+		title
+			block title
+				= title || 'Misskey'
+
+		block meta
+			meta(name='robots' content='noindex')
+
+		style
+			include ../style.embed.css
+
+		script.
+			var VERSION = "#{version}";
+			var CLIENT_ENTRY = "#{entry.file}";
+
+		script(type='application/json' id='misskey_meta' data-generated-at=now)
+			!= metaJson
+
+		script
+			include ../boot.embed.js
+
+	body
+		noscript: p
+			| JavaScriptを有効にしてください
+			br
+			| Please turn on your JavaScript
+		div#splash
+			img#splashIcon(src= icon || '/static-assets/splash.png')
+			div#splashSpinner
+				<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
+					<g transform="matrix(1,0,0,1,12,12)">
+						<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
+					</g>
+				</svg>
+				<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
+					<g transform="matrix(1,0,0,1,12,12)">
+						<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
+					</g>
+				</svg>
+		block content
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index da6d1eafd3c0395644c45350f95f938e1caaabe0..88714b255641c7544c5f4f84ac8482fa9b76918f 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -1,7 +1,7 @@
 block vars
 
 block loadClientEntry
-	- const clientEntry = config.clientEntry;
+	- const entry = config.frontendEntry;
 
 doctype html
 
@@ -36,13 +36,13 @@ html
 		link(rel='prefetch' href=serverErrorImageUrl)
 		link(rel='prefetch' href=infoImageUrl)
 		link(rel='prefetch' href=notFoundImageUrl)
-		link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
+		link(rel='modulepreload' href=`/vite/${entry.file}`)
 
-		if !config.clientManifestExists
+		if !config.frontendManifestExists
 				script(type="module" src="/vite/@vite/client")
 
-		if Array.isArray(clientEntry.css)
-			each href in clientEntry.css
+		if Array.isArray(entry.css)
+			each href in entry.css
 				link(rel='stylesheet' href=`/vite/${href}`)
 
 		title
@@ -68,7 +68,7 @@ html
 
 		script.
 			var VERSION = "#{version}";
-			var CLIENT_ENTRY = "#{clientEntry.file}";
+			var CLIENT_ENTRY = "#{entry.file}";
 
 		script(type='application/json' id='misskey_meta' data-generated-at=now)
 			!= metaJson
diff --git a/packages/frontend-embed/.gitignore b/packages/frontend-embed/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..1aa0ac14e8288adbba89868e9b9b1f231342679e
--- /dev/null
+++ b/packages/frontend-embed/.gitignore
@@ -0,0 +1 @@
+/storybook-static
diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1025d1bedbb8a6f69fef263191c43bbd677548be
--- /dev/null
+++ b/packages/frontend-embed/@types/global.d.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+type FIXME = any;
+
+declare const _LANGS_: string[][];
+declare const _VERSION_: string;
+declare const _ENV_: string;
+declare const _DEV_: boolean;
+declare const _PERF_PREFIX_: string;
+declare const _DATA_TRANSFER_DRIVE_FILE_: string;
+declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
+declare const _DATA_TRANSFER_DECK_COLUMN_: string;
+
+// for dev-mode
+declare const _LANGS_FULL_: string[][];
+
+// TagCanvas
+interface Window {
+	TagCanvas: any;
+}
diff --git a/packages/frontend-embed/@types/theme.d.ts b/packages/frontend-embed/@types/theme.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6ac1037493e672d5f38e593fcfb5e51b0cd6ef69
--- /dev/null
+++ b/packages/frontend-embed/@types/theme.d.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+declare module '@@/themes/*.json5' {
+	import { Theme } from '@/theme.js';
+
+	const theme: Theme;
+
+	export default theme;
+}
diff --git a/packages/frontend-embed/assets/dummy.png b/packages/frontend-embed/assets/dummy.png
new file mode 100644
index 0000000000000000000000000000000000000000..39332b0c1beeda1edb90d78d25c16e7372aff030
Binary files /dev/null and b/packages/frontend-embed/assets/dummy.png differ
diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..dd8f03dac5102ecb648e0beb6516a40c5421a621
--- /dev/null
+++ b/packages/frontend-embed/eslint.config.js
@@ -0,0 +1,95 @@
+import globals from 'globals';
+import tsParser from '@typescript-eslint/parser';
+import parser from 'vue-eslint-parser';
+import pluginVue from 'eslint-plugin-vue';
+import pluginMisskey from '@misskey-dev/eslint-plugin';
+import sharedConfig from '../shared/eslint.config.js';
+
+export default [
+	...sharedConfig,
+	{
+		files: ['src/**/*.vue'],
+		...pluginMisskey.configs.typescript,
+	},
+	...pluginVue.configs['flat/recommended'],
+	{
+		files: ['src/**/*.{ts,vue}'],
+		languageOptions: {
+			globals: {
+				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
+				...globals.browser,
+
+				// Node.js
+				module: false,
+				require: false,
+				__dirname: false,
+
+				// Misskey
+				_DEV_: false,
+				_LANGS_: false,
+				_VERSION_: false,
+				_ENV_: false,
+				_PERF_PREFIX_: false,
+				_DATA_TRANSFER_DRIVE_FILE_: false,
+				_DATA_TRANSFER_DRIVE_FOLDER_: false,
+				_DATA_TRANSFER_DECK_COLUMN_: false,
+			},
+			parser,
+			parserOptions: {
+				extraFileExtensions: ['.vue'],
+				parser: tsParser,
+				project: ['./tsconfig.json'],
+				sourceType: 'module',
+				tsconfigRootDir: import.meta.dirname,
+			},
+		},
+		rules: {
+			'@typescript-eslint/no-empty-interface': ['error', {
+				allowSingleExtends: true,
+			}],
+			// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
+			// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
+			'id-denylist': ['error', 'window', 'e'],
+			'no-shadow': ['warn'],
+			'vue/attributes-order': ['error', {
+				alphabetical: false,
+			}],
+			'vue/no-use-v-if-with-v-for': ['error', {
+				allowUsingIterationVar: false,
+			}],
+			'vue/no-ref-as-operand': 'error',
+			'vue/no-multi-spaces': ['error', {
+				ignoreProperties: false,
+			}],
+			'vue/no-v-html': 'warn',
+			'vue/order-in-components': 'error',
+			'vue/html-indent': ['warn', 'tab', {
+				attribute: 1,
+				baseIndent: 0,
+				closeBracket: 0,
+				alignAttributesVertically: true,
+				ignores: [],
+			}],
+			'vue/html-closing-bracket-spacing': ['warn', {
+				startTag: 'never',
+				endTag: 'never',
+				selfClosingTag: 'never',
+			}],
+			'vue/multi-word-component-names': 'warn',
+			'vue/require-v-for-key': 'warn',
+			'vue/no-unused-components': 'warn',
+			'vue/no-unused-vars': 'warn',
+			'vue/no-dupe-keys': 'warn',
+			'vue/valid-v-for': 'warn',
+			'vue/return-in-computed-property': 'warn',
+			'vue/no-setup-props-reactivity-loss': 'warn',
+			'vue/max-attributes-per-line': 'off',
+			'vue/html-self-closing': 'off',
+			'vue/singleline-html-element-content-newline': 'off',
+			'vue/v-on-event-hyphenation': ['error', 'never', {
+				autofix: true,
+			}],
+			'vue/attribute-hyphenation': ['error', 'never'],
+		},
+	},
+];
diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..a65d6ab657b85d40edf518c41d92f9274f0c7195
--- /dev/null
+++ b/packages/frontend-embed/package.json
@@ -0,0 +1,85 @@
+{
+	"name": "frontend-embed",
+	"private": true,
+	"type": "module",
+	"scripts": {
+		"watch": "vite",
+		"dev": "vite --config vite.config.local-dev.ts --debug hmr",
+		"build": "vite build",
+		"typecheck": "vue-tsc --noEmit",
+		"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
+		"lint": "pnpm typecheck && pnpm eslint"
+	},
+	"dependencies": {
+		"@discordapp/twemoji": "15.0.3",
+		"@github/webauthn-json": "2.1.1",
+		"@rollup/plugin-json": "6.1.0",
+		"@rollup/plugin-replace": "5.0.7",
+		"@rollup/pluginutils": "5.1.0",
+		"@tabler/icons-webfont": "3.3.0",
+		"@twemoji/parser": "15.1.1",
+		"@vitejs/plugin-vue": "5.1.0",
+		"@vue/compiler-sfc": "3.4.37",
+		"astring": "1.8.6",
+		"buraha": "0.0.1",
+		"compare-versions": "6.1.1",
+		"date-fns": "2.30.0",
+		"escape-regexp": "0.0.1",
+		"estree-walker": "3.0.3",
+		"eventemitter3": "5.0.1",
+		"idb-keyval": "6.2.1",
+		"is-file-animated": "1.0.2",
+		"mfm-js": "0.24.0",
+		"misskey-js": "workspace:*",
+		"frontend-shared": "workspace:*",
+		"punycode": "2.3.1",
+		"rollup": "4.19.1",
+		"sanitize-html": "2.13.0",
+		"sass": "1.77.8",
+		"shiki": "1.12.0",
+		"strict-event-emitter-types": "2.0.0",
+		"throttle-debounce": "5.0.2",
+		"tinycolor2": "1.6.0",
+		"tsc-alias": "1.8.10",
+		"tsconfig-paths": "4.2.0",
+		"typescript": "5.5.4",
+		"uuid": "10.0.0",
+		"json5": "2.2.3",
+		"vite": "5.3.5",
+		"vue": "3.4.37"
+	},
+	"devDependencies": {
+		"@misskey-dev/summaly": "5.1.0",
+		"@testing-library/vue": "8.1.0",
+		"@types/escape-regexp": "0.0.3",
+		"@types/estree": "1.0.5",
+		"@types/micromatch": "4.0.9",
+		"@types/node": "20.14.12",
+		"@types/punycode": "2.1.4",
+		"@types/sanitize-html": "2.11.0",
+		"@types/throttle-debounce": "5.0.2",
+		"@types/tinycolor2": "1.4.6",
+		"@types/uuid": "10.0.0",
+		"@types/ws": "8.5.11",
+		"@typescript-eslint/eslint-plugin": "7.17.0",
+		"@typescript-eslint/parser": "7.17.0",
+		"@vitest/coverage-v8": "1.6.0",
+		"@vue/runtime-core": "3.4.37",
+		"acorn": "8.12.1",
+		"cross-env": "7.0.3",
+		"eslint-plugin-import": "2.29.1",
+		"eslint-plugin-vue": "9.27.0",
+		"fast-glob": "3.3.2",
+		"happy-dom": "10.0.3",
+		"intersection-observer": "0.12.2",
+		"micromatch": "4.0.7",
+		"msw": "2.3.4",
+		"nodemon": "3.1.4",
+		"prettier": "3.3.3",
+		"start-server-and-test": "2.0.4",
+		"vite-plugin-turbosnap": "1.0.3",
+		"vue-component-type-helpers": "2.0.29",
+		"vue-eslint-parser": "9.4.3",
+		"vue-tsc": "2.0.29"
+	}
+}
diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4676baa9056e8aea525ddcd426255c0801557b31
--- /dev/null
+++ b/packages/frontend-embed/src/boot.ts
@@ -0,0 +1,114 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// https://vitejs.dev/config/build-options.html#build-modulepreload
+import 'vite/modulepreload-polyfill';
+
+import '@tabler/icons-webfont/dist/tabler-icons.scss';
+
+import '@/style.scss';
+import { createApp, defineAsyncComponent } from 'vue';
+import lightTheme from '@@/themes/l-light.json5';
+import darkTheme from '@@/themes/d-dark.json5';
+import { MediaProxy } from '@@/js/media-proxy.js';
+import { applyTheme } from './theme.js';
+import { fetchCustomEmojis } from './custom-emojis.js';
+import { DI } from './di.js';
+import { serverMetadata } from './server-metadata.js';
+import { url } from './config.js';
+import { parseEmbedParams } from '@@/js/embed-page.js';
+import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
+
+console.info('Misskey Embed');
+
+const params = new URLSearchParams(location.search);
+const embedParams = parseEmbedParams(params);
+
+console.info(embedParams);
+
+if (embedParams.colorMode === 'dark') {
+	applyTheme(darkTheme);
+} else if (embedParams.colorMode === 'light') {
+	applyTheme(lightTheme);
+} else {
+	if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+		applyTheme(darkTheme);
+	} else {
+		applyTheme(lightTheme);
+	}
+	window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
+		if (mql.matches) {
+			applyTheme(darkTheme);
+		} else {
+			applyTheme(lightTheme);
+		}
+	});
+}
+
+// サイズの制限
+document.documentElement.style.maxWidth = '500px';
+
+// iframeIdの設定
+function setIframeIdHandler(event: MessageEvent) {
+	if (event.data?.type === 'misskey:embedParent:registerIframeId' && event.data.payload?.iframeId != null) {
+		setIframeId(event.data.payload.iframeId);
+		window.removeEventListener('message', setIframeIdHandler);
+	}
+}
+
+window.addEventListener('message', setIframeIdHandler);
+
+try {
+	await fetchCustomEmojis();
+} catch (err) { /* empty */ }
+
+const app = createApp(
+	defineAsyncComponent(() => import('@/ui.vue')),
+);
+
+app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url));
+
+app.provide(DI.embedParams, embedParams);
+
+// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
+// なぜか2回実行されることがあるため、mountするdivを1つに制限する
+const rootEl = ((): HTMLElement => {
+	const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
+
+	const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+
+	if (currentRoot) {
+		console.warn('multiple import detected');
+		return currentRoot;
+	}
+
+	const root = document.createElement('div');
+	root.id = MISSKEY_MOUNT_DIV_ID;
+	document.body.appendChild(root);
+	return root;
+})();
+
+postMessageToParentWindow('misskey:embed:ready');
+
+app.mount(rootEl);
+
+// boot.jsのやつを解除
+window.onerror = null;
+window.onunhandledrejection = null;
+
+removeSplash();
+
+function removeSplash() {
+	const splash = document.getElementById('splash');
+	if (splash) {
+		splash.style.opacity = '0';
+		splash.style.pointerEvents = 'none';
+
+		// transitionendイベントが発火しない場合があるため
+		window.setTimeout(() => {
+			splash.remove();
+		}, 1000);
+	}
+}
diff --git a/packages/frontend-embed/src/components/EmA.vue b/packages/frontend-embed/src/components/EmA.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1c236b9a3592e32e189366dec590aa72ab9ecb4f
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmA.vue
@@ -0,0 +1,21 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="to" target="_blank" rel="noopener">
+	<slot></slot>
+</a>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+	to: string;
+	activeClass?: null | string;
+}>(), {
+	activeClass: null,
+});
+</script>
diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue
new file mode 100644
index 0000000000000000000000000000000000000000..07315e6a8b7cbb6576b4dcd084e6e89bdeeffe92
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmAcct.vue
@@ -0,0 +1,24 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<span>
+	<span>@{{ user.username }}</span>
+	<span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { toUnicode } from 'punycode/';
+import { host as hostRaw } from '@/config.js';
+
+defineProps<{
+	user: Misskey.entities.UserLite;
+	detail?: boolean;
+}>();
+
+const host = toUnicode(hostRaw);
+</script>
diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..58c35c8ef0cd96b18fe3ac53aef8defecd46dad3
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmAvatar.vue
@@ -0,0 +1,250 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="link ? EmA : 'span'" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat }]">
+	<EmImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
+	<div v-if="user.isCat" :class="[$style.ears]">
+		<div :class="$style.earLeft">
+			<div v-if="false" :class="$style.layer">
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+			</div>
+		</div>
+		<div :class="$style.earRight">
+			<div v-if="false" :class="$style.layer">
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+				<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+			</div>
+		</div>
+	</div>
+	<img
+		v-for="decoration in user.avatarDecorations"
+		:class="[$style.decoration]"
+		:src="getDecorationUrl(decoration)"
+		:style="{
+			rotate: getDecorationAngle(decoration),
+			scale: getDecorationScale(decoration),
+			translate: getDecorationOffset(decoration),
+		}"
+		alt=""
+	>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmImgWithBlurhash from './EmImgWithBlurhash.vue';
+import EmA from './EmA.vue';
+import { userPage } from '@/utils.js';
+
+const props = withDefaults(defineProps<{
+	user: Misskey.entities.User;
+	link?: boolean;
+	preview?: boolean;
+	indicator?: boolean;
+}>(), {
+	link: false,
+	preview: false,
+	indicator: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'click', v: MouseEvent): void;
+}>();
+
+const bound = computed(() => props.link
+	? { to: userPage(props.user) }
+	: {});
+
+const url = computed(() => {
+	if (props.user.avatarUrl == null) return null;
+	return props.user.avatarUrl;
+});
+
+function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+	return decoration.url;
+}
+
+function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+	const angle = decoration.angle ?? 0;
+	return angle === 0 ? undefined : `${angle * 360}deg`;
+}
+
+function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+	const scaleX = decoration.flipH ? -1 : 1;
+	return scaleX === 1 ? undefined : `${scaleX} 1`;
+}
+
+function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+	const offsetX = decoration.offsetX ?? 0;
+	const offsetY = decoration.offsetY ?? 0;
+	return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`;
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	display: inline-block;
+	vertical-align: bottom;
+	flex-shrink: 0;
+	border-radius: 100%;
+	line-height: 16px;
+}
+
+.inner {
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	top: 0;
+	border-radius: 100%;
+	z-index: 1;
+	overflow: clip;
+	object-fit: cover;
+	width: 100%;
+	height: 100%;
+}
+
+.indicator {
+	position: absolute;
+	z-index: 2;
+	bottom: 0;
+	left: 0;
+	width: 20%;
+	height: 20%;
+}
+
+.cat {
+	> .ears {
+		contain: strict;
+		position: absolute;
+		top: -50%;
+		left: -50%;
+		width: 100%;
+		height: 100%;
+		padding: 50%;
+		pointer-events: none;
+
+		> .earLeft,
+		> .earRight {
+			contain: strict;
+			display: inline-block;
+			height: 50%;
+			width: 50%;
+			background: currentColor;
+
+			&::after {
+				contain: strict;
+				content: '';
+				display: block;
+				width: 60%;
+				height: 60%;
+				margin: 20%;
+				background: #df548f;
+			}
+
+			> .layer {
+				contain: strict;
+				position: absolute;
+				top: 0;
+				width: 280%;
+				height: 280%;
+
+				> .plot {
+					contain: strict;
+					position: absolute;
+					width: 100%;
+					height: 100%;
+					clip-path: path('M0 0H1V1H0z');
+					transform: scale(32767);
+					transform-origin: 0 0;
+					opacity: 0.5;
+
+					&:first-child {
+						opacity: 1;
+					}
+
+					&:last-child {
+						opacity: calc(1 / 3);
+					}
+				}
+			}
+		}
+
+		> .earLeft {
+			transform: rotate(37.5deg) skew(30deg);
+
+			&, &::after {
+				border-radius: 25% 75% 75%;
+			}
+
+			> .layer {
+				left: 0;
+				transform:
+					skew(-30deg)
+					rotate(-37.5deg)
+					translate(-2.82842712475%, /* -2 * sqrt(2) */
+										-38.5857864376%); /* 40 - 2 * sqrt(2) */
+
+				> .plot {
+					background-position: 20% 10%; /* ~= 37.5deg */
+
+					&:first-child {
+						background-position-x: 21%;
+					}
+
+					&:last-child {
+						background-position-y: 11%;
+					}
+				}
+			}
+		}
+
+		> .earRight {
+			transform: rotate(-37.5deg) skew(-30deg);
+
+			&, &::after {
+				border-radius: 75% 25% 75% 75%;
+			}
+
+			> .layer {
+				right: 0;
+				transform:
+					skew(30deg)
+					rotate(37.5deg)
+					translate(2.82842712475%, /* 2 * sqrt(2) */
+										-38.5857864376%); /* 40 - 2 * sqrt(2) */
+
+				> .plot {
+					position: absolute;
+					background-position: 80% 10%; /* ~= 37.5deg */
+
+					&:first-child {
+						background-position-x: 79%;
+					}
+
+					&:last-child {
+						background-position-y: 11%;
+					}
+				}
+			}
+		}
+	}
+}
+
+.decoration {
+	position: absolute;
+	z-index: 1;
+	top: -50%;
+	left: -50%;
+	width: 200%;
+	pointer-events: none;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e4149cf363f33082203633eafb9d143b40603980
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmCustomEmoji.vue
@@ -0,0 +1,101 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<img
+	v-if="errored && fallbackToImage"
+	:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
+	src="/client-assets/dummy.png"
+	:title="alt"
+/>
+<span v-else-if="errored">:{{ customEmojiName }}:</span>
+<img
+	v-else
+	:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
+	:src="url"
+	:alt="alt"
+	:title="alt"
+	decoding="async"
+	@error="errored = true"
+	@load="errored = false"
+/>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from 'vue';
+import { customEmojisMap } from '@/custom-emojis.js';
+
+import { DI } from '@/di.js';
+
+const mediaProxy = inject(DI.mediaProxy)!;
+
+const props = defineProps<{
+	name: string;
+	normal?: boolean;
+	noStyle?: boolean;
+	host?: string | null;
+	url?: string;
+	useOriginalSize?: boolean;
+	menu?: boolean;
+	menuReaction?: boolean;
+	fallbackToImage?: boolean;
+}>();
+
+const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
+const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
+
+const rawUrl = computed(() => {
+	if (props.url) {
+		return props.url;
+	}
+	if (isLocal.value) {
+		return customEmojisMap.get(customEmojiName.value)?.url ?? null;
+	}
+	return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
+});
+
+const url = computed(() => {
+	if (rawUrl.value == null) return undefined;
+
+	const proxied =
+		(rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
+			? rawUrl.value
+			: mediaProxy.getProxiedImageUrl(
+				rawUrl.value,
+				props.useOriginalSize ? undefined : 'emoji',
+				false,
+				true,
+			);
+	return proxied;
+});
+
+const alt = computed(() => `:${customEmojiName.value}:`);
+const errored = ref(url.value == null);
+</script>
+
+<style lang="scss" module>
+.root {
+	height: 2em;
+	vertical-align: middle;
+	transition: transform 0.2s ease;
+
+	&:hover {
+		transform: scale(1.2);
+	}
+}
+
+.normal {
+	height: 1.25em;
+	vertical-align: -0.25em;
+
+	&:hover {
+		transform: none;
+	}
+}
+
+.noStyle {
+	height: auto !important;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmEmoji.vue b/packages/frontend-embed/src/components/EmEmoji.vue
new file mode 100644
index 0000000000000000000000000000000000000000..224979707b971c0a5e84c87a7619944946f9c358
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmEmoji.vue
@@ -0,0 +1,26 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<img :class="$style.root" :src="url" :alt="props.emoji" decoding="async"/>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { char2twemojiFilePath } from '@@/js/emoji-base.js';
+
+const props = defineProps<{
+	emoji: string;
+}>();
+
+const url = computed(() => char2twemojiFilePath(props.emoji));
+</script>
+
+<style lang="scss" module>
+.root {
+	height: 1.25em;
+	vertical-align: -0.25em;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmError.vue b/packages/frontend-embed/src/components/EmError.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d376b29a7f85cc3926cc02db18ee603cde187181
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmError.vue
@@ -0,0 +1,43 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+	<p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
+	<button class="_buttonGray _buttonRounded" :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from '@/i18n.js';
+
+const emit = defineEmits<{
+	(ev: 'retry'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 32px;
+	text-align: center;
+  align-items: center;
+}
+
+.text {
+	margin: 0 0 8px 0;
+}
+
+.button {
+	margin: 0 auto;
+}
+
+.img {
+	vertical-align: bottom;
+  width: 128px;
+	height: 128px;
+	margin-bottom: 16px;
+	border-radius: 16px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d19cd08d0a0791280e787132520f9389a14f792e
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue
@@ -0,0 +1,240 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''">
+	<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"/>
+</div>
+</template>
+
+<script lang="ts">
+import DrawBlurhash from '@/workers/draw-blurhash?worker';
+import TestWebGL2 from '@/workers/test-webgl2?worker';
+import { WorkerMultiDispatch } from '@/to-be-shared/worker-multi-dispatch.js';
+import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
+
+const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
+	// テスト環境で Web Worker インスタンスは作成できない
+	if (import.meta.env.MODE === 'test') {
+		const canvas = document.createElement('canvas');
+		canvas.width = 64;
+		canvas.height = 64;
+		resolve(canvas);
+		return;
+	}
+	const testWorker = new TestWebGL2();
+	testWorker.addEventListener('message', event => {
+		if (event.data.result) {
+			const workers = new WorkerMultiDispatch(
+				() => new DrawBlurhash(),
+				Math.min(navigator.hardwareConcurrency - 1, 4),
+			);
+			resolve(workers);
+			if (_DEV_) console.log('WebGL2 in worker is supported!');
+		} else {
+			const canvas = document.createElement('canvas');
+			canvas.width = 64;
+			canvas.height = 64;
+			resolve(canvas);
+			if (_DEV_) console.log('WebGL2 in worker is not supported...');
+		}
+		testWorker.terminate();
+	});
+});
+</script>
+
+<script lang="ts" setup>
+import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { render } from 'buraha';
+
+const props = withDefaults(defineProps<{
+	src?: string | null;
+	hash?: string | null;
+	alt?: string | null;
+	title?: string | null;
+	height?: number;
+	width?: number;
+	cover?: boolean;
+	forceBlurhash?: boolean;
+	onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
+}>(), {
+	src: null,
+	alt: '',
+	title: null,
+	height: 64,
+	width: 64,
+	cover: true,
+	forceBlurhash: false,
+	onlyAvgColor: false,
+});
+
+const viewId = uuid();
+const canvas = shallowRef<HTMLCanvasElement>();
+const root = shallowRef<HTMLDivElement>();
+const img = shallowRef<HTMLImageElement>();
+const loaded = ref(false);
+const canvasWidth = ref(64);
+const canvasHeight = ref(64);
+const imgWidth = ref(props.width);
+const imgHeight = ref(props.height);
+const bitmapTmp = ref<CanvasImageSource | undefined>();
+const hide = computed(() => !loaded.value || props.forceBlurhash);
+
+function waitForDecode() {
+	if (props.src != null && props.src !== '') {
+		nextTick()
+			.then(() => img.value?.decode())
+			.then(() => {
+				loaded.value = true;
+			}, error => {
+				console.log('Error occurred during decoding image', img.value, error);
+			});
+	} else {
+		loaded.value = false;
+	}
+}
+
+watch([() => props.width, () => props.height, root], () => {
+	const ratio = props.width / props.height;
+	if (ratio > 1) {
+		canvasWidth.value = Math.round(64 * ratio);
+		canvasHeight.value = 64;
+	} else {
+		canvasWidth.value = 64;
+		canvasHeight.value = Math.round(64 / ratio);
+	}
+
+	const clientWidth = root.value?.clientWidth ?? 300;
+	imgWidth.value = clientWidth;
+	imgHeight.value = Math.round(clientWidth / ratio);
+}, {
+	immediate: true,
+});
+
+function drawImage(bitmap: CanvasImageSource) {
+	// canvasがない(mountedされていない)場合はTmpに保存しておく
+	if (!canvas.value) {
+		bitmapTmp.value = bitmap;
+		return;
+	}
+
+	// canvasがあれば描画する
+	bitmapTmp.value = undefined;
+	const ctx = canvas.value.getContext('2d');
+	if (!ctx) return;
+	ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
+}
+
+function drawAvg() {
+	if (!canvas.value) return;
+
+	const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
+
+	const ctx = canvas.value.getContext('2d');
+	if (!ctx) return;
+
+	// avgColorでお茶をにごす
+	ctx.beginPath();
+	ctx.fillStyle = color;
+	ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
+}
+
+async function draw() {
+	if (import.meta.env.MODE === 'test' && props.hash == null) return;
+
+	drawAvg();
+
+	if (props.hash == null) return;
+
+	if (props.onlyAvgColor) return;
+
+	const work = await canvasPromise;
+	if (work instanceof WorkerMultiDispatch) {
+		work.postMessage(
+			{
+				id: viewId,
+				hash: props.hash,
+			},
+			undefined,
+		);
+	} else {
+		try {
+			render(props.hash, work);
+			drawImage(work);
+		} catch (error) {
+			console.error('Error occurred during drawing blurhash', error);
+		}
+	}
+}
+
+function workerOnMessage(event: MessageEvent) {
+	if (event.data.id !== viewId) return;
+	drawImage(event.data.bitmap as ImageBitmap);
+}
+
+canvasPromise.then(work => {
+	if (work instanceof WorkerMultiDispatch) {
+		work.addListener(workerOnMessage);
+	}
+
+	draw();
+});
+
+watch(() => props.src, () => {
+	waitForDecode();
+});
+
+watch(() => props.hash, () => {
+	draw();
+});
+
+onMounted(() => {
+	// drawImageがmountedより先に呼ばれている場合はここで描画する
+	if (bitmapTmp.value) {
+		drawImage(bitmapTmp.value);
+	}
+	waitForDecode();
+});
+
+onUnmounted(() => {
+	canvasPromise.then(work => {
+		if (work instanceof WorkerMultiDispatch) {
+			work.removeListener(workerOnMessage);
+		}
+	});
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	width: 100%;
+	height: 100%;
+
+	&.cover {
+		> .canvas,
+		> .img {
+			object-fit: cover;
+		}
+	}
+}
+
+.canvas,
+.img {
+	display: block;
+	width: 100%;
+	height: 100%;
+}
+
+.canvas {
+	object-fit: contain;
+}
+
+.img {
+	object-fit: contain;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue
new file mode 100644
index 0000000000000000000000000000000000000000..eeeaee528e75e2b3e61632adfbe8824b5e5ae667
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue
@@ -0,0 +1,87 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" :style="bg">
+	<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
+	<div :class="$style.name">{{ instance.name }}</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
+
+import { DI } from '@/di.js';
+
+const serverMetadata = inject(DI.serverMetadata)!;
+const mediaProxy = inject(DI.mediaProxy)!;
+
+const props = defineProps<{
+	instance?: {
+		faviconUrl?: string | null
+		name?: string | null
+		themeColor?: string | null
+	}
+}>();
+
+// if no instance data is given, this is for the local instance
+const instance = props.instance ?? {
+	name: serverMetadata.name,
+	themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
+};
+
+const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico');
+
+const themeColor = serverMetadata.themeColor ?? '#777777';
+
+const bg = {
+	background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
+};
+</script>
+
+<style lang="scss" module>
+$height: 2ex;
+
+.root {
+	display: flex;
+	align-items: center;
+	height: $height;
+	border-radius: 4px 0 0 4px;
+	overflow: clip;
+	color: #fff;
+	text-shadow: /* .866 ≈ sin(60deg) */
+		1px 0 1px #000,
+		.866px .5px 1px #000,
+		.5px .866px 1px #000,
+		0 1px 1px #000,
+		-.5px .866px 1px #000,
+		-.866px .5px 1px #000,
+		-1px 0 1px #000,
+		-.866px -.5px 1px #000,
+		-.5px -.866px 1px #000,
+		0 -1px 1px #000,
+		.5px -.866px 1px #000,
+		.866px -.5px 1px #000;
+	mask-image: linear-gradient(90deg,
+		rgb(0,0,0),
+		rgb(0,0,0) calc(100% - 16px),
+		rgba(0,0,0,0) 100%
+	);
+}
+
+.icon {
+	height: $height;
+	flex-shrink: 0;
+}
+
+.name {
+	margin-left: 4px;
+	line-height: 1;
+	font-size: 0.9em;
+	font-weight: bold;
+	white-space: nowrap;
+	overflow: visible;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmLink.vue b/packages/frontend-embed/src/components/EmLink.vue
new file mode 100644
index 0000000000000000000000000000000000000000..319ad723990efdec81a51ae6f831a206fc839a9f
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmLink.vue
@@ -0,0 +1,40 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component
+	:is="self ? EmA : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
+	:title="url"
+>
+	<slot></slot>
+	<i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import EmA from './EmA.vue';
+import { url as local } from '@/config.js';
+
+const props = withDefaults(defineProps<{
+	url: string;
+	rel?: null | string;
+}>(), {
+});
+
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+
+const el = ref<HTMLElement | { $el: HTMLElement }>();
+
+</script>
+
+<style lang="scss" module>
+.icon {
+	padding-left: 2px;
+	font-size: .9em;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue
new file mode 100644
index 0000000000000000000000000000000000000000..49d8ace37bee32d60c09783c03bfd738c12bbc03
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmLoading.vue
@@ -0,0 +1,112 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]">
+	<div :class="$style.container">
+		<svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+			<g transform="matrix(1.125,0,0,1.125,12,12)">
+				<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+			</g>
+		</svg>
+		<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+			<g transform="matrix(1.125,0,0,1.125,12,12)">
+				<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+			</g>
+		</svg>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+	static?: boolean;
+	inline?: boolean;
+	colored?: boolean;
+	mini?: boolean;
+	em?: boolean;
+}>(), {
+	static: false,
+	inline: false,
+	colored: true,
+	mini: false,
+	em: false,
+});
+</script>
+
+<style lang="scss" module>
+@keyframes spinner {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+
+.root {
+	padding: 32px;
+	text-align: center;
+	cursor: wait;
+
+	--size: 38px;
+
+	&.colored {
+		color: var(--accent);
+	}
+
+	&.inline {
+		display: inline;
+		padding: 0;
+		--size: 32px;
+	}
+
+	&.mini {
+		padding: 16px;
+		--size: 32px;
+	}
+
+	&.em {
+		display: inline-block;
+		vertical-align: middle;
+		padding: 0;
+		--size: 1em;
+	}
+}
+
+.container {
+	position: relative;
+	width: var(--size);
+	height: var(--size);
+	margin: 0 auto;
+}
+
+.spinner {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: var(--size);
+	height: var(--size);
+	fill-rule: evenodd;
+	clip-rule: evenodd;
+	stroke-linecap: round;
+	stroke-linejoin: round;
+	stroke-miterlimit: 1.5;
+}
+
+.bg {
+	opacity: 0.275;
+}
+
+.fg {
+	animation: spinner 0.5s linear infinite;
+
+	&.static {
+		animation-play-state: paused;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue
new file mode 100644
index 0000000000000000000000000000000000000000..435da238a4e689ae7ebf51fceee3869013472957
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaBanner.vue
@@ -0,0 +1,55 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="href" target="_blank" :class="$style.root">
+	<div :class="$style.label">
+		<template v-if="media.type.startsWith('audio')"><i class="ti ti-music"></i> {{ i18n.ts.audio }}</template>
+		<template v-else><i class="ti ti-file"></i> {{ i18n.ts.file }}</template>
+	</div>
+	<div :class="$style.go">
+		<i class="ti ti-chevron-right"></i>
+	</div>
+</a>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+defineProps<{
+	media: Misskey.entities.DriveFile;
+	href: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	box-sizing: border-box;
+	display: flex;
+	align-items: center;
+	width: 100%;
+	padding: var(--margin);
+	margin-top: 4px;
+	border: 1px solid var(--inputBorder);
+	border-radius: var(--radius);
+	background-color: var(--panel);
+	transition: background-color .1s, border-color .1s;
+
+	&:hover {
+		text-decoration: none;
+		border-color: var(--inputBorderHover);
+		background-color: var(--buttonHoverBg);
+	}
+}
+
+.label {
+	font-size: .9em;
+}
+
+.go {
+	margin-left: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue
new file mode 100644
index 0000000000000000000000000000000000000000..fe1aa5a877be200678b0acf75ae89328385a75ff
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaImage.vue
@@ -0,0 +1,154 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[hide ? $style.hidden : $style.visible]" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
+	<a
+		:title="image.name"
+		:class="$style.imageContainer"
+		:href="href ?? image.url"
+		target="_blank"
+		rel="noopener"
+	>
+		<ImgWithBlurhash
+			:hash="image.blurhash"
+			:src="hide ? null : url"
+			:forceBlurhash="hide"
+			:cover="hide || cover"
+			:alt="image.comment || image.name"
+			:title="image.comment || image.name"
+			:width="image.properties.width"
+			:height="image.properties.height"
+			:style="hide ? 'filter: brightness(0.7);' : null"
+		/>
+	</a>
+	<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 }}</b>
+				<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ i18n.ts.image }}</b>
+				<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
+			</div>
+		</div>
+	</template>
+	<div :class="$style.indicators">
+		<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
+		<div v-if="image.comment" :class="$style.indicator">ALT</div>
+		<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
+	</div>
+	<i v-if="!hide" class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
+import { i18n } from '@/i18n.js';
+
+const props = withDefaults(defineProps<{
+	image: Misskey.entities.DriveFile;
+	href?: string;
+	raw?: boolean;
+	cover?: boolean;
+}>(), {
+	cover: false,
+});
+
+const hide = ref(props.image.isSensitive);
+const darkMode = ref<boolean>(false); // TODO
+
+const url = computed(() => (props.raw)
+	? props.image.url
+	: props.image.thumbnailUrl,
+);
+
+async function onclick(ev: MouseEvent) {
+	if (hide.value) {
+		ev.stopPropagation();
+		hide.value = false;
+	}
+}
+</script>
+
+<style lang="scss" module>
+.hidden {
+	position: relative;
+}
+
+.hiddenText {
+	position: absolute;
+	left: 0;
+	top: 0;
+	width: 100%;
+	height: 100%;
+	z-index: 1;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	cursor: pointer;
+}
+
+.hide {
+	display: block;
+	position: absolute;
+	border-radius: 6px;
+	background-color: var(--fg);
+	color: var(--accentLighten);
+	font-size: 12px;
+	opacity: .5;
+	padding: 5px 8px;
+	text-align: center;
+	cursor: pointer;
+	top: 12px;
+	right: 12px;
+}
+
+.hiddenTextWrapper {
+	display: table-cell;
+	text-align: center;
+	font-size: 0.8em;
+	color: #fff;
+}
+
+.visible {
+	position: relative;
+	//box-shadow: 0 0 0 1px var(--divider) inset;
+	background: var(--bg);
+	background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
+	background-size: 16px 16px;
+}
+
+.imageContainer {
+	display: block;
+	overflow: hidden;
+	width: 100%;
+	height: 100%;
+	background-position: center;
+	background-size: contain;
+	background-repeat: no-repeat;
+}
+
+.indicators {
+	display: inline-flex;
+	position: absolute;
+	top: 10px;
+	left: 10px;
+	pointer-events: none;
+	opacity: .5;
+	gap: 6px;
+}
+
+.indicator {
+	/* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */
+	background-color: black;
+	border-radius: 6px;
+	color: var(--accentLighten);
+	display: inline-block;
+	font-weight: bold;
+	font-size: 0.8em;
+	padding: 2px 5px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaList.vue b/packages/frontend-embed/src/components/EmMediaList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0b2d835abe9ea2885ef623e2bb0f895ee32d183e
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaList.vue
@@ -0,0 +1,146 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner">
+		<XBanner :media="media" :href="originalEntityUrl"/>
+	</div>
+	<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
+		<div
+			:class="[
+				$style.medias,
+				count === 1 ? [$style.n1] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
+			]"
+		>
+			<div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media">
+				<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/>
+				<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import XBanner from './EmMediaBanner.vue';
+import XImage from './EmMediaImage.vue';
+import XVideo from './EmMediaVideo.vue';
+import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
+
+const props = defineProps<{
+	mediaList: Misskey.entities.DriveFile[];
+	raw?: boolean;
+
+	/** 埋め込みページ用 親要素の正規URL */
+	originalEntityUrl: string;
+}>();
+
+const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
+
+const previewable = (file: Misskey.entities.DriveFile): boolean => {
+	if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
+	// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
+	return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
+};
+</script>
+
+<style lang="scss" module>
+.container {
+	position: relative;
+	width: 100%;
+	margin-top: 4px;
+}
+
+.medias {
+	display: grid;
+	grid-gap: 8px;
+
+	height: 100%;
+	width: 100%;
+
+	&.n1 {
+		grid-template-rows: 1fr;
+
+		// default but fallback (expand)
+		min-height: 64px;
+		max-height: clamp(
+			64px,
+			50cqh,
+			min(360px, 50vh)
+		);
+
+		&.n116_9 {
+			min-height: initial;
+			max-height: initial;
+			aspect-ratio: 16 / 9; // fallback
+		}
+
+		&.n11_1{
+			min-height: initial;
+			max-height: initial;
+			aspect-ratio: 1 / 1; // fallback
+		}
+
+		&.n12_3 {
+			min-height: initial;
+			max-height: initial;
+			aspect-ratio: 2 / 3; // fallback
+		}
+	}
+
+	&.n2 {
+		aspect-ratio: 16/9;
+		grid-template-columns: 1fr 1fr;
+		grid-template-rows: 1fr;
+	}
+
+	&.n3 {
+		aspect-ratio: 16/9;
+		grid-template-columns: 1fr 0.5fr;
+		grid-template-rows: 1fr 1fr;
+
+		> .media:nth-child(1) {
+			grid-row: 1 / 3;
+		}
+
+		> .media:nth-child(3) {
+			grid-column: 2 / 3;
+			grid-row: 2 / 3;
+		}
+	}
+
+	&.n4 {
+		aspect-ratio: 16/9;
+		grid-template-columns: 1fr 1fr;
+		grid-template-rows: 1fr 1fr;
+	}
+
+	&.nMany {
+		grid-template-columns: 1fr 1fr;
+
+		> .media {
+			aspect-ratio: 16/9;
+		}
+	}
+}
+
+.media {
+	overflow: hidden; // clipにするとバグる
+	border-radius: 8px;
+	position: relative;
+
+	>.mediaInner {
+		width: 100%;
+		height: 100%;
+	}
+}
+
+.banner {
+	position: relative;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ce751f9acdf83fd49c5d61a4ffcd97ff46acba25
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaVideo.vue
@@ -0,0 +1,64 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="href" target="_blank" :class="$style.root">
+	<img v-if="!video.isSensitive && video.thumbnailUrl" :class="$style.thumbnail" :src="video.thumbnailUrl">
+	<div :class="$style.videoOverlayPlayButton"><i class="ti ti-player-play-filled"></i></div>
+</a>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+
+defineProps<{
+	video: Misskey.entities.DriveFile;
+	href: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	box-sizing: border-box;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 100%;
+	height: auto;
+	aspect-ratio: 16 / 9;
+	padding: var(--margin);
+	border: 1px solid var(--divider);
+	border-radius: var(--radius);
+	background-color: #000;
+
+	&:hover {
+		text-decoration: none;
+	}
+}
+
+.thumbnail {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+}
+
+.videoOverlayPlayButton {
+	background: var(--accent);
+	color: #fff;
+	padding: 1rem;
+	border-radius: 99rem;
+
+	font-size: 1rem;
+	line-height: 1rem;
+
+	&:focus-visible {
+		outline: none;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5eadf828c765bb59e52b9b67422b5b929b996d23
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMention.vue
@@ -0,0 +1,46 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkA v-user-preview="canonical" :class="[$style.root]" :to="url" :style="{ background: bgCss }">
+	<span>
+		<span>@{{ username }}</span>
+		<span v-if="(host != localHost)" :class="$style.host">@{{ toUnicode(host) }}</span>
+	</span>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { toUnicode } from 'punycode';
+import { } from 'vue';
+import tinycolor from 'tinycolor2';
+import { host as localHost } from '@/config.js';
+
+const props = defineProps<{
+	username: string;
+	host: string;
+}>();
+
+const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
+
+const url = `/${canonical}`;
+
+const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--mention'));
+bg.setAlpha(0.1);
+const bgCss = bg.toRgbString();
+</script>
+
+<style lang="scss" module>
+.root {
+	display: inline-block;
+	padding: 4px 8px 4px 4px;
+	border-radius: 999px;
+	color: var(--mention);
+}
+
+.host {
+	opacity: 0.5;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7543d3cd540d01691f7e86b5e8b6eb7850e2ec0a
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMfm.ts
@@ -0,0 +1,461 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { VNode, h, SetupContext, provide } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import EmUrl from '@/components/EmUrl.vue';
+import EmTime from '@/components/EmTime.vue';
+import EmLink from '@/components/EmLink.vue';
+import EmMention from '@/components/EmMention.vue';
+import EmEmoji from '@/components/EmEmoji.vue';
+import EmCustomEmoji from '@/components/EmCustomEmoji.vue';
+import EmA from '@/components/EmA.vue';
+import { host } from '@/config.js';
+
+function safeParseFloat(str: unknown): number | null {
+	if (typeof str !== 'string' || str === '') return null;
+	const num = parseFloat(str);
+	if (isNaN(num)) return null;
+	return num;
+}
+
+const QUOTE_STYLE = `
+display: block;
+margin: 8px;
+padding: 6px 0 6px 12px;
+color: var(--fg);
+border-left: solid 3px var(--fg);
+opacity: 0.7;
+`.split('\n').join(' ');
+
+type MfmProps = {
+	text: string;
+	plain?: boolean;
+	nowrap?: boolean;
+	author?: Misskey.entities.UserLite;
+	isNote?: boolean;
+	emojiUrls?: Record<string, string>;
+	rootScale?: number;
+	nyaize?: boolean | 'respect';
+	parsedNodes?: mfm.MfmNode[] | null;
+	enableEmojiMenu?: boolean;
+	enableEmojiMenuReaction?: boolean;
+	linkNavigationBehavior?: string;
+};
+
+type MfmEvents = {
+	clickEv(id: string): void;
+};
+
+// eslint-disable-next-line import/no-default-export
+export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
+	provide('linkNavigationBehavior', props.linkNavigationBehavior);
+
+	const isNote = props.isNote ?? true;
+	const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
+
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	if (props.text == null || props.text === '') return;
+
+	const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
+
+	const validTime = (t: string | boolean | null | undefined) => {
+		if (t == null) return null;
+		if (typeof t === 'boolean') return null;
+		return t.match(/^\-?[0-9.]+s$/) ? t : null;
+	};
+
+	const validColor = (c: unknown): string | null => {
+		if (typeof c !== 'string') return null;
+		return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
+	};
+
+	const useAnim = true;
+
+	/**
+	 * Gen Vue Elements from MFM AST
+	 * @param ast MFM AST
+	 * @param scale How times large the text is
+	 * @param disableNyaize Whether nyaize is disabled or not
+	 */
+	const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => {
+		switch (token.type) {
+			case 'text': {
+				let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+				if (!disableNyaize && shouldNyaize) {
+					text = Misskey.nyaize(text);
+				}
+
+				if (!props.plain) {
+					const res: (VNode | string)[] = [];
+					for (const t of text.split('\n')) {
+						res.push(h('br'));
+						res.push(t);
+					}
+					res.shift();
+					return res;
+				} else {
+					return [text.replace(/\n/g, ' ')];
+				}
+			}
+
+			case 'bold': {
+				return [h('b', genEl(token.children, scale))];
+			}
+
+			case 'strike': {
+				return [h('del', genEl(token.children, scale))];
+			}
+
+			case 'italic': {
+				return h('i', {
+					style: 'font-style: oblique;',
+				}, genEl(token.children, scale));
+			}
+
+			case 'fn': {
+				// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+				let style: string | undefined;
+				switch (token.props.name) {
+					case 'tada': {
+						const speed = validTime(token.props.args.speed) ?? '1s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
+						break;
+					}
+					case 'jelly': {
+						const speed = validTime(token.props.args.speed) ?? '1s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : '');
+						break;
+					}
+					case 'twitch': {
+						const speed = validTime(token.props.args.speed) ?? '0.5s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : '';
+						break;
+					}
+					case 'shake': {
+						const speed = validTime(token.props.args.speed) ?? '0.5s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : '';
+						break;
+					}
+					case 'spin': {
+						const direction =
+							token.props.args.left ? 'reverse' :
+							token.props.args.alternate ? 'alternate' :
+							'normal';
+						const anime =
+							token.props.args.x ? 'mfm-spinX' :
+							token.props.args.y ? 'mfm-spinY' :
+							'mfm-spin';
+						const speed = validTime(token.props.args.speed) ?? '1.5s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : '';
+						break;
+					}
+					case 'jump': {
+						const speed = validTime(token.props.args.speed) ?? '0.75s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : '';
+						break;
+					}
+					case 'bounce': {
+						const speed = validTime(token.props.args.speed) ?? '0.75s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : '';
+						break;
+					}
+					case 'flip': {
+						const transform =
+							(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
+							token.props.args.v ? 'scaleY(-1)' :
+							'scaleX(-1)';
+						style = `transform: ${transform};`;
+						break;
+					}
+					case 'x2': {
+						return h('span', {
+							class: 'mfm-x2',
+						}, genEl(token.children, scale * 2));
+					}
+					case 'x3': {
+						return h('span', {
+							class: 'mfm-x3',
+						}, genEl(token.children, scale * 3));
+					}
+					case 'x4': {
+						return h('span', {
+							class: 'mfm-x4',
+						}, genEl(token.children, scale * 4));
+					}
+					case 'font': {
+						const family =
+							token.props.args.serif ? 'serif' :
+							token.props.args.monospace ? 'monospace' :
+							token.props.args.cursive ? 'cursive' :
+							token.props.args.fantasy ? 'fantasy' :
+							token.props.args.emoji ? 'emoji' :
+							token.props.args.math ? 'math' :
+							null;
+						if (family) style = `font-family: ${family};`;
+						break;
+					}
+					case 'blur': {
+						return h('span', {
+							class: '_mfm_blur_',
+						}, genEl(token.children, scale));
+					}
+					case 'rainbow': {
+						if (!useAnim) {
+							return h('span', {
+								class: '_mfm_rainbow_fallback_',
+							}, genEl(token.children, scale));
+						}
+						const speed = validTime(token.props.args.speed) ?? '1s';
+						const delay = validTime(token.props.args.delay) ?? '0s';
+						style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`;
+						break;
+					}
+					case 'sparkle': {
+						return genEl(token.children, scale);
+					}
+					case 'rotate': {
+						const degrees = safeParseFloat(token.props.args.deg) ?? 90;
+						style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
+						break;
+					}
+					case 'position': {
+						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': {
+						const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5);
+						const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5);
+						style = `transform: scale(${x}, ${y});`;
+						scale = scale * Math.max(x, y);
+						break;
+					}
+					case 'fg': {
+						let color = validColor(token.props.args.color);
+						color = color ?? 'f00';
+						style = `color: #${color}; overflow-wrap: anywhere;`;
+						break;
+					}
+					case 'bg': {
+						let color = validColor(token.props.args.color);
+						color = color ?? 'f00';
+						style = `background-color: #${color}; overflow-wrap: anywhere;`;
+						break;
+					}
+					case 'border': {
+						let color = validColor(token.props.args.color);
+						color = color ? `#${color}` : 'var(--accent)';
+						let b_style = token.props.args.style;
+						if (
+							typeof b_style !== 'string' ||
+							!['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']
+								.includes(b_style)
+						) b_style = 'solid';
+						const width = safeParseFloat(token.props.args.width) ?? 1;
+						const radius = safeParseFloat(token.props.args.radius) ?? 0;
+						style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`;
+						break;
+					}
+					case 'ruby': {
+						if (token.children.length === 1) {
+							const child = token.children[0];
+							let text = child.type === 'text' ? child.props.text : '';
+							if (!disableNyaize && shouldNyaize) {
+								text = Misskey.nyaize(text);
+							}
+							return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
+						} else {
+							const rt = token.children.at(-1)!;
+							let text = rt.type === 'text' ? rt.props.text : '';
+							if (!disableNyaize && shouldNyaize) {
+								text = Misskey.nyaize(text);
+							}
+							return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
+						}
+					}
+					case 'unixtime': {
+						const child = token.children[0];
+						const unixtime = parseInt(child.type === 'text' ? child.props.text : '');
+						return h('span', {
+							style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;',
+						}, [
+							h('i', {
+								class: 'ti ti-clock',
+								style: 'margin-right: 0.25em;',
+							}),
+							h(EmTime, {
+								key: Math.random(),
+								time: unixtime * 1000,
+								mode: 'detail',
+							}),
+						]);
+					}
+					case 'clickable': {
+						return h('span', { onClick(ev: MouseEvent): void {
+							ev.stopPropagation();
+							ev.preventDefault();
+							const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : '';
+							emit('clickEv', clickEv);
+						} }, genEl(token.children, scale));
+					}
+				}
+				if (style === undefined) {
+					return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
+				} else {
+					return h('span', {
+						style: 'display: inline-block; ' + style,
+					}, genEl(token.children, scale));
+				}
+			}
+
+			case 'small': {
+				return [h('small', {
+					style: 'opacity: 0.7;',
+				}, genEl(token.children, scale))];
+			}
+
+			case 'center': {
+				return [h('div', {
+					style: 'text-align:center;',
+				}, genEl(token.children, scale))];
+			}
+
+			case 'url': {
+				return [h(EmUrl, {
+					key: Math.random(),
+					url: token.props.url,
+					rel: 'nofollow noopener',
+				})];
+			}
+
+			case 'link': {
+				return [h(EmLink, {
+					key: Math.random(),
+					url: token.props.url,
+					rel: 'nofollow noopener',
+				}, genEl(token.children, scale, true))];
+			}
+
+			case 'mention': {
+				return [h(EmMention, {
+					key: Math.random(),
+					host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
+					username: token.props.username,
+				})];
+			}
+
+			case 'hashtag': {
+				return [h(EmA, {
+					key: Math.random(),
+					to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
+					style: 'color:var(--hashtag);',
+				}, `#${token.props.hashtag}`)];
+			}
+
+			case 'blockCode': {
+				return [h('code', {
+					key: Math.random(),
+					lang: token.props.lang ?? undefined,
+				}, token.props.code)];
+			}
+
+			case 'inlineCode': {
+				return [h('code', {
+					key: Math.random(),
+				}, token.props.code)];
+			}
+
+			case 'quote': {
+				if (!props.nowrap) {
+					return [h('div', {
+						style: QUOTE_STYLE,
+					}, genEl(token.children, scale, true))];
+				} else {
+					return [h('span', {
+						style: QUOTE_STYLE,
+					}, genEl(token.children, scale, true))];
+				}
+			}
+
+			case 'emojiCode': {
+				if (props.author?.host == null) {
+					return [h(EmCustomEmoji, {
+						key: Math.random(),
+						name: token.props.name,
+						normal: props.plain,
+						host: null,
+						useOriginalSize: scale >= 2.5,
+						menu: props.enableEmojiMenu,
+						menuReaction: props.enableEmojiMenuReaction,
+						fallbackToImage: false,
+					})];
+				} else {
+					// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+					if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) {
+						return [h('span', `:${token.props.name}:`)];
+					} else {
+						return [h(EmCustomEmoji, {
+							key: Math.random(),
+							name: token.props.name,
+							url: props.emojiUrls && props.emojiUrls[token.props.name],
+							normal: props.plain,
+							host: props.author.host,
+							useOriginalSize: scale >= 2.5,
+						})];
+					}
+				}
+			}
+
+			case 'unicodeEmoji': {
+				return [h(EmEmoji, {
+					key: Math.random(),
+					emoji: token.props.emoji,
+					menu: props.enableEmojiMenu,
+					menuReaction: props.enableEmojiMenuReaction,
+				})];
+			}
+
+			case 'mathInline': {
+				return [h('code', token.props.formula)];
+			}
+
+			case 'mathBlock': {
+				return [h('code', token.props.formula)];
+			}
+
+			case 'search': {
+				return [h('div', {
+					key: Math.random(),
+				}, token.props.query)];
+			}
+
+			case 'plain': {
+				return [h('span', genEl(token.children, scale, true))];
+			}
+
+			default: {
+				// eslint-disable-next-line @typescript-eslint/no-explicit-any
+				console.error('unrecognized ast type:', (token as any).type);
+
+				return [];
+			}
+		}
+	}).flat(Infinity) as (VNode | string)[];
+
+	return h('span', {
+		// https://codeday.me/jp/qa/20190424/690106.html
+		style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
+	}, genEl(rootAst, props.rootScale ?? 1));
+}
diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7c4d5910660bf0e47c476a365c74d66295d9c5a5
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -0,0 +1,609 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	v-show="!isDeleted"
+	ref="rootEl"
+	:class="[$style.root]"
+	:tabindex="isDeleted ? '-1' : '0'"
+>
+	<EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+	<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
+	<!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
+	<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
+	<div v-if="isRenote" :class="$style.renote">
+		<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
+		<EmAvatar :class="$style.renoteAvatar" :user="note.user" link/>
+		<i class="ti ti-repeat" style="margin-right: 4px;"></i>
+		<I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
+			<template #user>
+				<EmA v-user-preview="true ? undefined : note.userId" :class="$style.renoteUserName" :to="userPage(note.user)">
+					<EmUserName :user="note.user"/>
+				</EmA>
+			</template>
+		</I18n>
+		<div :class="$style.renoteInfo">
+			<button ref="renoteTime" :class="$style.renoteTime" class="_button">
+				<i class="ti ti-dots" :class="$style.renoteMenu"></i>
+				<EmTime :time="note.createdAt"/>
+			</button>
+			<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
+				<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+				<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+				<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+			</span>
+			<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+			<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
+		</div>
+	</div>
+	<article :class="$style.article">
+		<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
+		<EmAvatar :class="$style.avatar" :user="appearNote.user" link/>
+		<div :class="$style.main">
+			<EmNoteHeader :note="appearNote" :mini="true"/>
+			<div style="container-type: inline-size;">
+				<p v-if="appearNote.cw != null" :class="$style.cw">
+					<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
+					<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+				</p>
+				<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
+					<div :class="$style.text">
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+						<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+						<EmMfm
+							v-if="appearNote.text"
+							:parsedNodes="parsed"
+							:text="appearNote.text"
+							:author="appearNote.user"
+							:nyaize="'respect'"
+							:emojiUrls="appearNote.emojis"
+							:enableEmojiMenu="!true"
+							:enableEmojiMenuReaction="true"
+						/>
+					</div>
+					<div v-if="appearNote.files && appearNote.files.length > 0">
+						<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+					</div>
+					<EmPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
+					<div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+					<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
+						<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
+					</button>
+					<button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+						<span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+					</button>
+				</div>
+				<EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA>
+			</div>
+			<EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16">
+				<template #more>
+					<EmA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA>
+				</template>
+			</EmReactionsViewer>
+			<footer :class="$style.footer">
+				<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+					<i class="ti ti-arrow-back-up"></i>
+				</a>
+				<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+					<i class="ti ti-repeat"></i>
+				</a>
+				<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+					<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
+					<i v-else class="ti ti-plus"></i>
+				</a>
+				<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+					<i class="ti ti-dots"></i>
+				</a>
+			</footer>
+		</div>
+	</article>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import I18n from '@/components/I18n.vue';
+import EmNoteSub from '@/components/EmNoteSub.vue';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmNoteSimple from '@/components/EmNoteSimple.vue';
+import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import EmMfm from '@/components/EmMfm.js';
+import EmA from '@/components/EmA.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmTime from '@/components/EmTime.vue';
+import { userPage } from '@/utils.js';
+import { i18n } from '@/i18n.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import { url } from '@/config.js';
+
+function getAppearNote(note: Misskey.entities.Note) {
+	return Misskey.note.isPureRenote(note) ? note.renote : note;
+}
+
+const props = withDefaults(defineProps<{
+	note: Misskey.entities.Note;
+	pinned?: boolean;
+}>(), {
+});
+
+const emit = defineEmits<{
+	(ev: 'reaction', emoji: string): void;
+	(ev: 'removeReaction', emoji: string): void;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const note = ref((props.note));
+
+const isRenote = Misskey.note.isPureRenote(note.value);
+
+const rootEl = shallowRef<HTMLElement>();
+const renoteTime = shallowRef<HTMLElement>();
+const appearNote = computed(() => getAppearNote(note.value));
+const showContent = ref(false);
+const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
+const isLong = shouldCollapsed(appearNote.value, []);
+const collapsed = ref(appearNote.value.cw == null && isLong);
+const isDeleted = ref(false);
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	transition: box-shadow 0.1s ease;
+	font-size: 1.05em;
+	overflow: clip;
+	contain: content;
+
+	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+	//content-visibility: auto;
+  //contain-intrinsic-size: 0 128px;
+
+	&:focus-visible {
+		outline: none;
+
+		&::after {
+			content: "";
+			pointer-events: none;
+			display: block;
+			position: absolute;
+			z-index: 10;
+			top: 0;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			margin: auto;
+			width: calc(100% - 8px);
+			height: calc(100% - 8px);
+			border: dashed 2px var(--focus);
+			border-radius: var(--radius);
+			box-sizing: border-box;
+		}
+	}
+
+	.footer {
+		position: relative;
+		z-index: 1;
+	}
+
+	&:hover > .article > .main > .footer > .footerButton {
+		opacity: 1;
+	}
+
+	&.showActionsOnlyHover {
+		.footer {
+			visibility: hidden;
+			position: absolute;
+			top: 12px;
+			right: 12px;
+			padding: 0 4px;
+			margin-bottom: 0 !important;
+			background: var(--popup);
+			border-radius: 8px;
+			box-shadow: 0px 4px 32px var(--shadow);
+		}
+
+		.footerButton {
+			font-size: 90%;
+
+			&:not(:last-child) {
+				margin-right: 0;
+			}
+		}
+	}
+
+	&.showActionsOnlyHover:hover {
+		.footer {
+			visibility: visible;
+		}
+	}
+}
+
+.tip {
+	display: flex;
+	align-items: center;
+	padding: 16px 32px 8px 32px;
+	line-height: 24px;
+	font-size: 90%;
+	white-space: pre;
+	color: #d28a3f;
+}
+
+.tip + .article {
+	padding-top: 8px;
+}
+
+.replyTo {
+	opacity: 0.7;
+	padding-bottom: 0;
+}
+
+.renote {
+	position: relative;
+	display: flex;
+	align-items: center;
+	padding: 16px 32px 8px 32px;
+	line-height: 28px;
+	white-space: pre;
+	color: var(--renote);
+
+	& + .article {
+		padding-top: 8px;
+	}
+
+	> .colorBar {
+		height: calc(100% - 6px);
+	}
+}
+
+.renoteAvatar {
+	flex-shrink: 0;
+	display: inline-block;
+	width: 28px;
+	height: 28px;
+	margin: 0 8px 0 0;
+}
+
+.renoteText {
+	overflow: hidden;
+	flex-shrink: 1;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.renoteUserName {
+	font-weight: bold;
+}
+
+.renoteInfo {
+	margin-left: auto;
+	font-size: 0.9em;
+}
+
+.renoteTime {
+	flex-shrink: 0;
+	color: inherit;
+}
+
+.renoteMenu {
+	margin-right: 4px;
+}
+
+.collapsedRenoteTarget {
+	display: flex;
+	align-items: center;
+	line-height: 28px;
+	white-space: pre;
+	padding: 0 32px 18px;
+}
+
+.collapsedRenoteTargetAvatar {
+	flex-shrink: 0;
+	display: inline-block;
+	width: 28px;
+	height: 28px;
+	margin: 0 8px 0 0;
+}
+
+.collapsedRenoteTargetText {
+	overflow: hidden;
+	flex-shrink: 1;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	font-size: 90%;
+	opacity: 0.7;
+	cursor: pointer;
+
+	&:hover {
+		text-decoration: underline;
+	}
+}
+
+.article {
+	position: relative;
+	display: flex;
+	padding: 28px 32px;
+}
+
+.colorBar {
+	position: absolute;
+	top: 8px;
+	left: 8px;
+	width: 5px;
+	height: calc(100% - 16px);
+	border-radius: 999px;
+	pointer-events: none;
+}
+
+.avatar {
+	flex-shrink: 0;
+	display: block !important;
+	margin: 0 14px 0 0;
+	width: 58px;
+	height: 58px;
+	position: sticky !important;
+	top: calc(22px + var(--stickyTop, 0px));
+	left: 0;
+}
+
+.main {
+	flex: 1;
+	min-width: 0;
+}
+
+.cw {
+	cursor: default;
+	display: block;
+	margin: 0;
+	padding: 0;
+	overflow-wrap: break-word;
+}
+
+.showLess {
+	width: 100%;
+	margin-top: 14px;
+	position: sticky;
+	bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+	display: inline-block;
+	background: var(--popup);
+	padding: 6px 10px;
+	font-size: 0.8em;
+	border-radius: 999px;
+	box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.contentCollapsed {
+	position: relative;
+	max-height: 9em;
+	overflow: clip;
+}
+
+.collapsed {
+	display: block;
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	z-index: 2;
+	width: 100%;
+	height: 64px;
+	background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
+
+	&:hover > .collapsedLabel {
+		background: var(--panelHighlight);
+	}
+}
+
+.collapsedLabel {
+	display: inline-block;
+	background: var(--panel);
+	padding: 6px 10px;
+	font-size: 0.8em;
+	border-radius: 999px;
+	box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.text {
+	overflow-wrap: break-word;
+}
+
+.replyIcon {
+	color: var(--accent);
+	margin-right: 0.5em;
+}
+
+.translation {
+	border: solid 0.5px var(--divider);
+	border-radius: var(--radius);
+	padding: 12px;
+	margin-top: 8px;
+}
+
+.urlPreview {
+	margin-top: 8px;
+}
+
+.poll {
+	font-size: 80%;
+}
+
+.quote {
+	padding: 8px 0;
+}
+
+.quoteNote {
+	padding: 16px;
+	border: dashed 1px var(--renote);
+	border-radius: 8px;
+	overflow: clip;
+}
+
+.channel {
+	opacity: 0.7;
+	font-size: 80%;
+}
+
+.footer {
+	margin-bottom: -14px;
+}
+
+.footerButton {
+	margin: 0;
+	padding: 8px;
+	opacity: 0.7;
+
+	&:not(:last-child) {
+		margin-right: 28px;
+	}
+
+	&:hover {
+		color: var(--fgHighlighted);
+	}
+}
+
+.footerButtonLink:hover,
+.footerButtonLink:focus,
+.footerButtonLink:active {
+	text-decoration: none;
+}
+
+.footerButtonCount {
+	display: inline;
+	margin: 0 0 0 8px;
+	opacity: 0.7;
+}
+
+@container (max-width: 580px) {
+	.root {
+		font-size: 0.95em;
+	}
+
+	.renote {
+		padding: 12px 26px 0 26px;
+	}
+
+	.article {
+		padding: 24px 26px;
+	}
+
+	.avatar {
+		width: 50px;
+		height: 50px;
+	}
+}
+
+@container (max-width: 500px) {
+	.root {
+		font-size: 0.9em;
+	}
+
+	.renote {
+		padding: 10px 22px 0 22px;
+	}
+
+	.article {
+		padding: 20px 22px;
+	}
+
+	.footer {
+		margin-bottom: -8px;
+	}
+}
+
+@container (max-width: 480px) {
+	.renote {
+		padding: 8px 16px 0 16px;
+	}
+
+	.tip {
+		padding: 8px 16px 0 16px;
+	}
+
+	.collapsedRenoteTarget {
+		padding: 0 16px 9px;
+		margin-top: 4px;
+	}
+
+	.article {
+		padding: 14px 16px;
+	}
+}
+
+@container (max-width: 450px) {
+	.avatar {
+		margin: 0 10px 0 0;
+		width: 46px;
+		height: 46px;
+		top: calc(14px + var(--stickyTop, 0px));
+	}
+}
+
+@container (max-width: 400px) {
+	.root:not(.showActionsOnlyHover) {
+		.footerButton {
+			&:not(:last-child) {
+				margin-right: 18px;
+			}
+		}
+	}
+}
+
+@container (max-width: 350px) {
+	.root:not(.showActionsOnlyHover) {
+		.footerButton {
+			&:not(:last-child) {
+				margin-right: 12px;
+			}
+		}
+	}
+
+	.colorBar {
+		top: 6px;
+		left: 6px;
+		width: 4px;
+		height: calc(100% - 12px);
+	}
+}
+
+@container (max-width: 300px) {
+	.avatar {
+		width: 44px;
+		height: 44px;
+	}
+
+	.root:not(.showActionsOnlyHover) {
+		.footerButton {
+			&:not(:last-child) {
+				margin-right: 8px;
+			}
+		}
+	}
+}
+
+@container (max-width: 250px) {
+	.quoteNote {
+		padding: 12px;
+	}
+}
+
+.reactionOmitted {
+	display: inline-block;
+	margin-left: 8px;
+	opacity: .8;
+	font-size: 95%;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue
new file mode 100644
index 0000000000000000000000000000000000000000..74a26856c84c0b623b124ffca4c188eab988a578
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue
@@ -0,0 +1,486 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	v-show="!isDeleted"
+	ref="rootEl"
+	:class="$style.root"
+>
+	<EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+	<div v-if="isRenote" :class="$style.renote">
+		<EmAvatar :class="$style.renoteAvatar" :user="note.user" link/>
+		<i class="ti ti-repeat" style="margin-right: 4px;"></i>
+		<span :class="$style.renoteText">
+			<I18n :src="i18n.ts.renotedBy" tag="span">
+				<template #user>
+					<EmA :class="$style.renoteName" :to="userPage(note.user)">
+						<EmUserName :user="note.user"/>
+					</EmA>
+				</template>
+			</I18n>
+		</span>
+		<div :class="$style.renoteInfo">
+			<div class="$style.renoteTime">
+				<EmTime :time="note.createdAt"/>
+			</div>
+			<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
+				<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+				<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+				<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+			</span>
+			<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+		</div>
+	</div>
+	<article :class="$style.note">
+		<header :class="$style.noteHeader">
+			<EmAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/>
+			<div :class="$style.noteHeaderBody">
+				<div :class="$style.noteHeaderBodyUpper">
+					<div style="min-width: 0;">
+						<div class="_nowrap">
+							<EmA :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
+								<EmUserName :nowrap="true" :user="appearNote.user"/>
+							</EmA>
+							<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
+						</div>
+						<div :class="$style.noteHeaderUsername"><EmAcct :user="appearNote.user"/></div>
+					</div>
+					<div :class="$style.noteHeaderInfo">
+						<a :href="url" :class="$style.noteHeaderInstanceIconLink" target="_blank" rel="noopener noreferrer">
+							<img :src="serverMetadata.iconUrl || '/favicon.ico'" alt="" :class="$style.noteHeaderInstanceIcon"/>
+						</a>
+					</div>
+				</div>
+			</div>
+		</header>
+		<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
+			<p v-if="appearNote.cw != null" :class="$style.cw">
+				<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
+				<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+			</p>
+			<div v-show="appearNote.cw == null || showContent">
+				<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+				<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+				<EmMfm
+					v-if="appearNote.text"
+					:parsedNodes="parsed"
+					:text="appearNote.text"
+					:author="appearNote.user"
+					:nyaize="'respect'"
+					:emojiUrls="appearNote.emojis"
+				/>
+				<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
+				<div v-if="appearNote.files && appearNote.files.length > 0">
+					<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+				</div>
+				<EmPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
+				<div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+				<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
+					<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
+				</button>
+				<button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+					<span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+				</button>
+			</div>
+			<EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA>
+		</div>
+		<footer>
+			<div :class="$style.noteFooterInfo">
+				<span v-if="appearNote.visibility !== 'public'" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
+					<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
+					<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
+					<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+				</span>
+				<span v-if="appearNote.localOnly" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+				<EmA :to="notePage(appearNote)">
+					<EmTime :time="appearNote.createdAt" mode="detail" colored/>
+				</EmA>
+			</div>
+			<EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :maxNumber="16" :note="appearNote">
+				<template #more>
+					<EmA :to="`/notes/${appearNote.id}`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA>
+				</template>
+			</EmReactionsViewer>
+			<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+				<i class="ti ti-arrow-back-up"></i>
+			</a>
+			<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+				<i class="ti ti-repeat"></i>
+				<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.renoteCount) }}</p>
+			</a>
+			<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+				<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
+				<i v-else class="ti ti-plus"></i>
+				<p v-if="(appearNote.reactionAcceptance === 'likeOnly') && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.reactionCount) }}</p>
+			</a>
+			<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+				<i class="ti ti-dots"></i>
+			</a>
+		</footer>
+	</article>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import I18n from '@/components/I18n.vue';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmNoteSub from '@/components/EmNoteSub.vue';
+import EmNoteSimple from '@/components/EmNoteSimple.vue';
+import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import EmA from '@/components/EmA.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmTime from '@/components/EmTime.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmAcct from '@/components/EmAcct.vue';
+import { userPage } from '@/utils.js';
+import { notePage } from '@/utils.js';
+import { i18n } from '@/i18n.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url } from '@/config.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+	note: Misskey.entities.Note;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const note = ref(props.note);
+
+const isRenote = (
+	note.value.renote != null &&
+	note.value.reply == null &&
+	note.value.text == null &&
+	note.value.cw == null &&
+	note.value.fileIds && note.value.fileIds.length === 0 &&
+	note.value.poll == null
+);
+
+const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
+const showContent = ref(false);
+const isDeleted = ref(false);
+const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
+const isLong = shouldCollapsed(appearNote.value, []);
+const collapsed = ref(appearNote.value.cw == null && isLong);
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	transition: box-shadow 0.1s ease;
+	overflow: clip;
+	contain: content;
+}
+
+.replyTo {
+	opacity: 0.7;
+	padding-bottom: 0;
+}
+
+.renote {
+	display: flex;
+	align-items: center;
+	padding: 16px 32px 8px 32px;
+	line-height: 28px;
+	white-space: pre;
+	color: var(--renote);
+}
+
+.renoteAvatar {
+	flex-shrink: 0;
+	display: inline-block;
+	width: 28px;
+	height: 28px;
+	margin: 0 8px 0 0;
+	border-radius: 6px;
+}
+
+.renoteText {
+	overflow: hidden;
+	flex-shrink: 1;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.renoteName {
+	font-weight: bold;
+}
+
+.renoteInfo {
+	margin-left: auto;
+	font-size: 0.9em;
+}
+
+.renoteTime {
+	flex-shrink: 0;
+	color: inherit;
+}
+
+.renote + .note {
+	padding-top: 8px;
+}
+
+.note {
+	padding: 24px 32px 16px;
+	font-size: 1.2em;
+
+	&:hover > .main > .footer > .button {
+		opacity: 1;
+	}
+}
+
+.noteHeader {
+	display: flex;
+	position: relative;
+	margin-bottom: 16px;
+	align-items: center;
+}
+
+.noteHeaderAvatar {
+	display: block;
+	flex-shrink: 0;
+	width: 50px;
+	height: 50px;
+}
+
+.noteHeaderBody {
+	flex: 1;
+	display: flex;
+	min-width: 0;
+	flex-direction: column;
+	justify-content: center;
+	padding-left: 16px;
+	font-size: 0.95em;
+}
+
+.noteHeaderBodyUpper {
+	display: flex;
+	min-width: 0;
+}
+
+.noteHeaderName {
+	font-weight: bold;
+	line-height: 1.3;
+}
+
+.isBot {
+	display: inline-block;
+	margin: 0 0.5em;
+	padding: 4px 6px;
+	font-size: 80%;
+	line-height: 1;
+	border: solid 0.5px var(--divider);
+	border-radius: 4px;
+}
+
+.noteHeaderInfo {
+	margin-left: auto;
+	display: flex;
+	gap: 0.5em;
+	align-items: center;
+}
+
+.noteHeaderInstanceIconLink {
+	display: inline-block;
+	margin-left: 4px;
+}
+
+.noteHeaderInstanceIcon {
+	width: 32px;
+	height: 32px;
+	border-radius: 4px;
+}
+
+.noteHeaderUsername {
+	margin-bottom: 2px;
+	line-height: 1.3;
+	word-wrap: anywhere;
+}
+
+.noteContent {
+	container-type: inline-size;
+	overflow-wrap: break-word;
+}
+
+.cw {
+	cursor: default;
+	display: block;
+	margin: 0;
+	padding: 0;
+	overflow-wrap: break-word;
+}
+
+.noteReplyTarget {
+	color: var(--accent);
+	margin-right: 0.5em;
+}
+
+.rn {
+	margin-left: 4px;
+	font-style: oblique;
+	color: var(--renote);
+}
+
+.reactionOmitted {
+	display: inline-block;
+	margin-left: 8px;
+	opacity: .8;
+	font-size: 95%;
+}
+
+.poll {
+	font-size: 80%;
+}
+
+.quote {
+	padding: 8px 0;
+}
+
+.quoteNote {
+	padding: 16px;
+	border: dashed 1px var(--renote);
+	border-radius: 8px;
+	overflow: clip;
+}
+
+.channel {
+	opacity: 0.7;
+	font-size: 80%;
+}
+
+.showLess {
+	width: 100%;
+	margin-top: 14px;
+	position: sticky;
+	bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+	display: inline-block;
+	background: var(--popup);
+	padding: 6px 10px;
+	font-size: 0.8em;
+	border-radius: 999px;
+	box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.contentCollapsed {
+	position: relative;
+	max-height: 9em;
+	overflow: clip;
+}
+
+.collapsed {
+	display: block;
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	z-index: 2;
+	width: 100%;
+	height: 64px;
+	background: linear-gradient(0deg, var(--panel), var(--X15));
+
+	&:hover > .collapsedLabel {
+		background: var(--panelHighlight);
+	}
+}
+
+.collapsedLabel {
+	display: inline-block;
+	background: var(--panel);
+	padding: 6px 10px;
+	font-size: 0.8em;
+	border-radius: 999px;
+	box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.noteFooterInfo {
+	margin: 16px 0;
+	opacity: 0.7;
+	font-size: 0.9em;
+}
+
+.noteFooterButton {
+	margin: 0;
+	padding: 8px;
+	opacity: 0.7;
+
+	&:not(:last-child) {
+		margin-right: 28px;
+	}
+
+	&:hover {
+		color: var(--fgHighlighted);
+	}
+}
+
+.footerButtonLink:hover,
+.footerButtonLink:focus,
+.footerButtonLink:active {
+	text-decoration: none;
+}
+
+.noteFooterButtonCount {
+	display: inline;
+	margin: 0 0 0 8px;
+	opacity: 0.7;
+
+	&.reacted {
+		color: var(--accent);
+	}
+}
+
+@container (max-width: 500px) {
+	.root {
+		font-size: 0.9em;
+	}
+}
+
+@container (max-width: 450px) {
+	.renote {
+		padding: 8px 16px 0 16px;
+	}
+
+	.note {
+		padding: 16px;
+	}
+
+	.noteHeaderAvatar {
+		width: 50px;
+		height: 50px;
+	}
+}
+
+@container (max-width: 350px) {
+	.noteFooterButton {
+		&:not(:last-child) {
+			margin-right: 18px;
+		}
+	}
+}
+
+@container (max-width: 300px) {
+	.root {
+		font-size: 0.825em;
+	}
+
+	.noteHeaderAvatar {
+		width: 50px;
+		height: 50px;
+	}
+
+	.noteFooterButton {
+		&:not(:last-child) {
+			margin-right: 12px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e4add9501f416182ca81ac42422ddc40666d77f6
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteHeader.vue
@@ -0,0 +1,104 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<header :class="$style.root">
+	<EmA :class="$style.name" :to="userPage(note.user)">
+		<EmUserName :user="note.user"/>
+	</EmA>
+	<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
+	<div :class="$style.username"><EmAcct :user="note.user"/></div>
+	<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
+		<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
+	</div>
+	<div :class="$style.info">
+		<EmA :to="notePage(note)">
+			<EmTime :time="note.createdAt" colored/>
+		</EmA>
+		<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;">
+			<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+			<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+			<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+		</span>
+		<span v-if="note.localOnly" style="margin-left: 0.5em;"><i class="ti ti-rocket-off"></i></span>
+		<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
+	</div>
+</header>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import { notePage } from '@/utils.js';
+import { userPage } from '@/utils.js';
+import EmA from '@/components/EmA.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmAcct from '@/components/EmAcct.vue';
+import EmTime from '@/components/EmTime.vue';
+
+defineProps<{
+	note: Misskey.entities.Note;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	display: flex;
+	align-items: baseline;
+	white-space: nowrap;
+}
+
+.name {
+	flex-shrink: 1;
+	display: block;
+	margin: 0 .5em 0 0;
+	padding: 0;
+	overflow: hidden;
+	font-size: 1em;
+	font-weight: bold;
+	text-decoration: none;
+	text-overflow: ellipsis;
+
+	&:hover {
+		text-decoration: underline;
+	}
+}
+
+.isBot {
+	flex-shrink: 0;
+	align-self: center;
+	margin: 0 .5em 0 0;
+	padding: 1px 6px;
+	font-size: 80%;
+	border: solid 0.5px var(--divider);
+	border-radius: 3px;
+}
+
+.username {
+	flex-shrink: 9999999;
+	margin: 0 .5em 0 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+.info {
+	flex-shrink: 0;
+	margin-left: auto;
+	font-size: 0.9em;
+}
+
+.badgeRoles {
+	margin: 0 .5em 0 0;
+}
+
+.badgeRole {
+	height: 1.3em;
+	vertical-align: -20%;
+
+	& + .badgeRole {
+		margin-left: 0.2em;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue
new file mode 100644
index 0000000000000000000000000000000000000000..828b6cd2e2ba05017c7b774c9f080bdedf088b07
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteSimple.vue
@@ -0,0 +1,105 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+	<EmAvatar :class="$style.avatar" :user="note.user" link preview/>
+	<div :class="$style.main">
+		<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
+		<div>
+			<p v-if="note.cw != null" :class="$style.cw">
+				<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+				<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+			</p>
+			<div v-show="note.cw == null || showContent">
+				<EmSubNoteContent :class="$style.text" :note="note"/>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+	note: Misskey.entities.Note;
+}>();
+
+const showContent = ref(false);
+</script>
+
+<style lang="scss" module>
+.root {
+	display: flex;
+	margin: 0;
+	padding: 0;
+	font-size: 0.95em;
+}
+
+.avatar {
+	flex-shrink: 0;
+	display: block;
+	margin: 0 10px 0 0;
+	width: 34px;
+	height: 34px;
+	border-radius: 8px;
+	position: sticky !important;
+	top: calc(16px + var(--stickyTop, 0px));
+	left: 0;
+}
+
+.main {
+	flex: 1;
+	min-width: 0;
+}
+
+.header {
+	margin-bottom: 2px;
+}
+
+.cw {
+	cursor: default;
+	display: block;
+	margin: 0;
+	padding: 0;
+	overflow-wrap: break-word;
+}
+
+.text {
+	cursor: default;
+	margin: 0;
+	padding: 0;
+}
+
+@container (min-width: 250px) {
+	.avatar {
+		margin: 0 10px 0 0;
+		width: 40px;
+		height: 40px;
+	}
+}
+
+@container (min-width: 350px) {
+	.avatar {
+		margin: 0 10px 0 0;
+		width: 44px;
+		height: 44px;
+	}
+}
+
+@container (min-width: 500px) {
+	.avatar {
+		margin: 0 12px 0 0;
+		width: 48px;
+		height: 48px;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c98b956805bf4cfb6af82dd34d554396543ef0fc
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteSub.vue
@@ -0,0 +1,149 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.children]: depth > 1 }]">
+	<div :class="$style.main">
+		<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
+		<EmAvatar :class="$style.avatar" :user="note.user" link preview/>
+		<div :class="$style.body">
+			<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
+			<div>
+				<p v-if="note.cw != null" :class="$style.cw">
+					<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
+					<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+				</p>
+				<div v-show="note.cw == null || showContent">
+					<EmSubNoteContent :class="$style.text" :note="note"/>
+				</div>
+			</div>
+		</div>
+	</div>
+	<template v-if="depth < 5">
+		<EmNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1"/>
+	</template>
+	<div v-else :class="$style.more">
+		<EmA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></EmA>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
+import { notePage } from '@/utils.js';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = withDefaults(defineProps<{
+	note: Misskey.entities.Note;
+	detail?: boolean;
+
+	// how many notes are in between this one and the note being viewed in detail
+	depth?: number;
+}>(), {
+	depth: 1,
+});
+
+const showContent = ref(false);
+const replies = ref<Misskey.entities.Note[]>([]);
+
+if (props.detail) {
+	misskeyApi('notes/children', {
+		noteId: props.note.id,
+		limit: 5,
+	}).then(res => {
+		replies.value = res;
+	});
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 16px 32px;
+	font-size: 0.9em;
+	position: relative;
+
+	&.children {
+		padding: 10px 0 0 16px;
+		font-size: 1em;
+	}
+}
+
+.main {
+	display: flex;
+}
+
+.colorBar {
+	position: absolute;
+	top: 8px;
+	left: 8px;
+	width: 5px;
+	height: calc(100% - 8px);
+	border-radius: 999px;
+	pointer-events: none;
+}
+
+.avatar {
+	flex-shrink: 0;
+	display: block;
+	margin: 0 8px 0 0;
+	width: 38px;
+	height: 38px;
+	border-radius: 8px;
+}
+
+.body {
+	flex: 1;
+	min-width: 0;
+}
+
+.header {
+	margin-bottom: 2px;
+}
+
+.cw {
+	cursor: default;
+	display: block;
+	margin: 0;
+	padding: 0;
+	overflow-wrap: break-word;
+}
+
+.text {
+	margin: 0;
+	padding: 0;
+}
+
+.reply, .more {
+	border-left: solid 0.5px var(--divider);
+	margin-top: 10px;
+}
+
+.more {
+	padding: 10px 0 0 16px;
+}
+
+@container (max-width: 450px) {
+	.root {
+		padding: 14px 16px;
+
+		&.children {
+			padding: 10px 0 0 8px;
+		}
+	}
+}
+
+.muted {
+	text-align: center;
+	padding: 8px !important;
+	border: 1px solid var(--divider);
+	margin: 8px 8px 0 8px;
+	border-radius: 8px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3970d050988f2bc3c464579fd8c1c7cf2ab8fedb
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNotes.vue
@@ -0,0 +1,48 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
+	<template #empty>
+		<div class="_fullinfo">
+			<div>{{ i18n.ts.noNotes }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: notes }">
+		<div :class="[$style.root]">
+			<EmNote v-for="note in notes" :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
+		</div>
+	</template>
+</EmPagination>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef } from 'vue';
+import EmNote from '@/components/EmNote.vue';
+import EmPagination, { Paging } from '@/components/EmPagination.vue';
+import { i18n } from '@/i18n.js';
+
+const props = withDefaults(defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+	disableAutoLoad?: boolean;
+	ad?: boolean;
+}>(), {
+	ad: true,
+});
+
+const pagingComponent = shallowRef<InstanceType<typeof EmPagination>>();
+
+defineExpose({
+	pagingComponent,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	background: var(--panel);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5d5317a91281ac62afbf56f119ca86b5d27cb6bd
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmPagination.vue
@@ -0,0 +1,504 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmLoading v-if="fetching"/>
+
+<EmError v-else-if="error" @retry="init()"/>
+
+<div v-else-if="empty" key="_empty_" class="empty">
+	<slot name="empty">
+		<div class="_fullinfo">
+			<div>{{ i18n.ts.nothing }}</div>
+		</div>
+	</slot>
+</div>
+
+<div v-else ref="rootEl">
+	<div v-show="pagination.reversed && more" key="_more_" class="_margin">
+		<button v-if="!moreFetching" class="_buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreAhead">
+			{{ i18n.ts.loadMore }}
+		</button>
+		<EmLoading v-else class="loading"/>
+	</div>
+	<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
+	<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
+		<button v-if="!moreFetching" class="_buttonRounded _buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
+			{{ i18n.ts.loadMore }}
+		</button>
+		<EmLoading v-else class="loading"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, 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';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+
+const SECOND_FETCH_LIMIT = 30;
+const TOLERANCE = 16;
+const APPEAR_MINIMUM_INTERVAL = 600;
+
+export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
+	endpoint: E;
+	limit: number;
+	params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
+
+	/**
+	 * 検索APIのような、ページング不可なエンドポイントを利用する場合
+	 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
+	 */
+	noPaging?: boolean;
+
+	/**
+	 * items 配列の中身を逆順にする(新しい方が最後)
+	 */
+	reversed?: boolean;
+
+	offsetMode?: boolean;
+
+	pageEl?: HTMLElement;
+};
+
+type MisskeyEntity = {
+	id: string;
+	createdAt: string;
+	_shouldInsertAd_?: boolean;
+	[x: string]: any;
+};
+
+type MisskeyEntityMap = Map<string, MisskeyEntity>;
+
+function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
+	return entities.map(en => [en.id, en]);
+}
+
+function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
+	return new Map([...map, ...arrayToEntries(entities)]);
+}
+
+</script>
+<script lang="ts" setup>
+import EmError from '@/components/EmError.vue';
+import EmLoading from '@/components/EmLoading.vue';
+
+const props = withDefaults(defineProps<{
+	pagination: Paging;
+	disableAutoLoad?: boolean;
+	displayLimit?: number;
+}>(), {
+	displayLimit: 20,
+});
+
+const emit = defineEmits<{
+	(ev: 'queue', count: number): void;
+	(ev: 'status', error: boolean): void;
+}>();
+
+const rootEl = shallowRef<HTMLElement>();
+
+// 遡り中かどうか
+const backed = ref(false);
+
+const scrollRemove = ref<(() => void) | null>(null);
+
+/**
+ * 表示するアイテムのソース
+ * 最新が0番目
+ */
+const items = ref<MisskeyEntityMap>(new Map());
+
+/**
+ * タブが非アクティブなどの場合に更新を貯めておく
+ * 最新が0番目
+ */
+const queue = ref<MisskeyEntityMap>(new Map());
+
+const offset = ref(0);
+
+/**
+ * 初期化中かどうか(trueならEmLoadingで全て隠す)
+ */
+const fetching = ref(true);
+
+const moreFetching = ref(false);
+const more = ref(false);
+const preventAppearFetchMore = ref(false);
+const preventAppearFetchMoreTimer = ref<number | null>(null);
+const isBackTop = ref(false);
+const empty = computed(() => items.value.size === 0);
+const error = ref(false);
+
+const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
+const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
+
+const visibility = useDocumentVisibility();
+
+let isPausingUpdate = false;
+let timerForSetPause: number | null = null;
+const BACKGROUND_PAUSE_WAIT_SEC = 10;
+
+// 先頭が表示されているかどうかを検出
+// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
+const scrollObserver = ref<IntersectionObserver>();
+
+watch([() => props.pagination.reversed, scrollableElement], () => {
+	if (scrollObserver.value) scrollObserver.value.disconnect();
+
+	scrollObserver.value = new IntersectionObserver(entries => {
+		backed.value = entries[0].isIntersecting;
+	}, {
+		root: scrollableElement.value,
+		rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
+		threshold: 0.01,
+	});
+}, { immediate: true });
+
+watch(rootEl, () => {
+	scrollObserver.value?.disconnect();
+	nextTick(() => {
+		if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
+	});
+});
+
+watch([backed, contentEl], () => {
+	if (!backed.value) {
+		if (!contentEl.value) return;
+
+		scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
+	} else {
+		if (scrollRemove.value) scrollRemove.value();
+		scrollRemove.value = null;
+	}
+});
+
+// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
+watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
+
+watch(queue, (a, b) => {
+	if (a.size === 0 && b.size === 0) return;
+	emit('queue', queue.value.size);
+}, { deep: true });
+
+watch(error, (n, o) => {
+	if (n === o) return;
+	emit('status', n);
+});
+
+async function init(): Promise<void> {
+	items.value = new Map();
+	queue.value = new Map();
+	fetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+		...params,
+		limit: props.pagination.limit ?? 10,
+		allowPartial: true,
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			if (i === 3) item._shouldInsertAd_ = true;
+		}
+
+		if (res.length === 0 || props.pagination.noPaging) {
+			concatItems(res);
+			more.value = false;
+		} else {
+			if (props.pagination.reversed) moreFetching.value = true;
+			concatItems(res);
+			more.value = true;
+		}
+
+		offset.value = res.length;
+		error.value = false;
+		fetching.value = false;
+	}, err => {
+		error.value = true;
+		fetching.value = false;
+	});
+}
+
+const reload = (): Promise<void> => {
+	return init();
+};
+
+const fetchMore = async (): Promise<void> => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
+	moreFetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : {
+			untilId: Array.from(items.value.keys()).at(-1),
+		}),
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			if (i === 10) item._shouldInsertAd_ = true;
+		}
+
+		const reverseConcat = _res => {
+			const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
+			const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
+
+			items.value = concatMapWithArray(items.value, _res);
+
+			return nextTick(() => {
+				if (scrollableElement.value) {
+					scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
+				} else {
+					window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
+				}
+
+				return nextTick();
+			});
+		};
+
+		if (res.length === 0) {
+			if (props.pagination.reversed) {
+				reverseConcat(res).then(() => {
+					more.value = false;
+					moreFetching.value = false;
+				});
+			} else {
+				items.value = concatMapWithArray(items.value, res);
+				more.value = false;
+				moreFetching.value = false;
+			}
+		} else {
+			if (props.pagination.reversed) {
+				reverseConcat(res).then(() => {
+					more.value = true;
+					moreFetching.value = false;
+				});
+			} else {
+				items.value = concatMapWithArray(items.value, res);
+				more.value = true;
+				moreFetching.value = false;
+			}
+		}
+		offset.value += res.length;
+	}, err => {
+		moreFetching.value = false;
+	});
+};
+
+const fetchMoreAhead = async (): Promise<void> => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
+	moreFetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : {
+			sinceId: Array.from(items.value.keys()).at(-1),
+		}),
+	}).then(res => {
+		if (res.length === 0) {
+			items.value = concatMapWithArray(items.value, res);
+			more.value = false;
+		} else {
+			items.value = concatMapWithArray(items.value, res);
+			more.value = true;
+		}
+		offset.value += res.length;
+		moreFetching.value = false;
+	}, err => {
+		moreFetching.value = false;
+	});
+};
+
+/**
+ * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
+ * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
+ */
+const fetchMoreApperTimeoutFn = (): void => {
+	preventAppearFetchMore.value = false;
+	preventAppearFetchMoreTimer.value = null;
+};
+const fetchMoreAppearTimeout = (): void => {
+	preventAppearFetchMore.value = true;
+	preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
+};
+
+const appearFetchMore = async (): Promise<void> => {
+	if (preventAppearFetchMore.value) return;
+	await fetchMore();
+	fetchMoreAppearTimeout();
+};
+
+const appearFetchMoreAhead = async (): Promise<void> => {
+	if (preventAppearFetchMore.value) return;
+	await fetchMoreAhead();
+	fetchMoreAppearTimeout();
+};
+
+const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+
+watch(visibility, () => {
+	if (visibility.value === 'hidden') {
+		timerForSetPause = window.setTimeout(() => {
+			isPausingUpdate = true;
+			timerForSetPause = null;
+		},
+		BACKGROUND_PAUSE_WAIT_SEC * 1000);
+	} else { // 'visible'
+		if (timerForSetPause) {
+			clearTimeout(timerForSetPause);
+			timerForSetPause = null;
+		} else {
+			isPausingUpdate = false;
+			if (isTop()) {
+				executeQueue();
+			}
+		}
+	}
+});
+
+/**
+ * 最新のものとして1つだけアイテムを追加する
+ * ストリーミングから降ってきたアイテムはこれで追加する
+ * @param item アイテム
+ */
+const prepend = (item: MisskeyEntity): void => {
+	if (items.value.size === 0) {
+		items.value.set(item.id, item);
+		fetching.value = false;
+		return;
+	}
+
+	if (isTop() && !isPausingUpdate) unshiftItems([item]);
+	else prependQueue(item);
+};
+
+/**
+ * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
+ * @param newItems 新しいアイテムの配列
+ */
+function unshiftItems(newItems: MisskeyEntity[]) {
+	const length = newItems.length + items.value.size;
+	items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
+
+	if (length >= props.displayLimit) more.value = true;
+}
+
+/**
+ * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
+ * @param oldItems 古いアイテムの配列
+ */
+function concatItems(oldItems: MisskeyEntity[]) {
+	const length = oldItems.length + items.value.size;
+	items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
+
+	if (length >= props.displayLimit) more.value = true;
+}
+
+function executeQueue() {
+	unshiftItems(Array.from(queue.value.values()));
+	queue.value = new Map();
+}
+
+function prependQueue(newItem: MisskeyEntity) {
+	queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
+}
+
+/*
+ * アイテムを末尾に追加する(使うの?)
+ */
+const appendItem = (item: MisskeyEntity): void => {
+	items.value.set(item.id, item);
+};
+
+const removeItem = (id: string) => {
+	items.value.delete(id);
+	queue.value.delete(id);
+};
+
+const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
+	const item = items.value.get(id);
+	if (item) items.value.set(id, replacer(item));
+
+	const queueItem = queue.value.get(id);
+	if (queueItem) queue.value.set(id, replacer(queueItem));
+};
+
+onActivated(() => {
+	isBackTop.value = false;
+});
+
+onDeactivated(() => {
+	isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
+});
+
+function toBottom() {
+	scrollToBottom(contentEl.value!);
+}
+
+onBeforeMount(() => {
+	init().then(() => {
+		if (props.pagination.reversed) {
+			nextTick(() => {
+				setTimeout(toBottom, 800);
+
+				// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
+				// more = trueを遅らせる
+				setTimeout(() => {
+					moreFetching.value = false;
+				}, 2000);
+			});
+		}
+	});
+});
+
+onBeforeUnmount(() => {
+	if (timerForSetPause) {
+		clearTimeout(timerForSetPause);
+		timerForSetPause = null;
+	}
+	if (preventAppearFetchMoreTimer.value) {
+		clearTimeout(preventAppearFetchMoreTimer.value);
+		preventAppearFetchMoreTimer.value = null;
+	}
+	scrollObserver.value?.disconnect();
+});
+
+defineExpose({
+	items,
+	queue,
+	backed: backed.value,
+	more,
+	reload,
+	prepend,
+	append: appendItem,
+	removeItem,
+	updateItem,
+});
+</script>
+
+<style lang="scss" module>
+.transition_fade_enterActive,
+.transition_fade_leaveActive {
+	transition: opacity 0.125s ease;
+}
+.transition_fade_enterFrom,
+.transition_fade_leaveTo {
+	opacity: 0;
+}
+
+.more {
+	display: block;
+	margin-left: auto;
+	margin-right: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a2b1203449403116027b4cd0b17725392269d68f
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmPoll.vue
@@ -0,0 +1,82 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<ul :class="$style.choices">
+		<li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice">
+			<div :class="$style.bg" :style="{ 'width': `${choice.votes / total * 100}%` }"></div>
+			<span :class="$style.fg">
+				<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
+				<EmMfm :text="choice.text" :plain="true"/>
+				<span style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
+			</span>
+		</li>
+	</ul>
+	<p :class="$style.info">
+		<span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span>
+	</p>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import EmMfm from '@/components/EmMfm.js';
+
+function sum(xs: number[]): number {
+	return xs.reduce((a, b) => a + b, 0);
+}
+
+const props = defineProps<{
+	noteId: string;
+	poll: NonNullable<Misskey.entities.Note['poll']>;
+}>();
+
+const total = computed(() => sum(props.poll.choices.map(x => x.votes)));
+</script>
+
+<style lang="scss" module>
+.choices {
+	display: block;
+	margin: 0;
+	padding: 0;
+	list-style: none;
+}
+
+.choice {
+	display: block;
+	position: relative;
+	margin: 4px 0;
+	padding: 4px;
+	//border: solid 0.5px var(--divider);
+	background: var(--accentedBg);
+	border-radius: 4px;
+	overflow: clip;
+}
+
+.bg {
+	position: absolute;
+	top: 0;
+	left: 0;
+	height: 100%;
+	background: var(--accent);
+	background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
+	transition: width 1s ease;
+}
+
+.fg {
+	position: relative;
+	display: inline-block;
+	padding: 3px 5px;
+	background: var(--panel);
+	border-radius: 3px;
+}
+
+.info {
+	color: var(--fg);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmReactionIcon.vue b/packages/frontend-embed/src/components/EmReactionIcon.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5c38ecb0ed3b4a192afb15da3e1ebe8a72c3a40a
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionIcon.vue
@@ -0,0 +1,23 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
+<EmEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import EmCustomEmoji from './EmCustomEmoji.vue';
+import EmEmoji from './EmEmoji.vue';
+
+const props = defineProps<{
+	reaction: string;
+	noStyle?: boolean;
+	emojiUrl?: string;
+	withTooltip?: boolean;
+}>();
+
+</script>
diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2e43eb8d170be6df285048ec15483e2b3c0d3b4a
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue
@@ -0,0 +1,99 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<button
+	class="_button"
+	:class="[$style.root, { [$style.reacted]: note.myReaction == reaction }]"
+>
+	<EmReactionIcon :class="$style.limitWidth" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
+	<span :class="$style.count">{{ count }}</span>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmReactionIcon from '@/components/EmReactionIcon.vue';
+
+const props = defineProps<{
+	reaction: string;
+	count: number;
+	isInitial: boolean;
+	note: Misskey.entities.Note;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	display: inline-flex;
+	height: 42px;
+	margin: 2px;
+	padding: 0 6px;
+	font-size: 1.5em;
+	border-radius: 6px;
+	align-items: center;
+	justify-content: center;
+
+	&.canToggle {
+		background: var(--buttonBg);
+
+		&:hover {
+			background: rgba(0, 0, 0, 0.1);
+		}
+	}
+
+	&:not(.canToggle) {
+		cursor: default;
+	}
+
+	&.small {
+		height: 32px;
+		font-size: 1em;
+		border-radius: 4px;
+
+		> .count {
+			font-size: 0.9em;
+			line-height: 32px;
+		}
+	}
+
+	&.large {
+		height: 52px;
+		font-size: 2em;
+		border-radius: 8px;
+
+		> .count {
+			font-size: 0.6em;
+			line-height: 52px;
+		}
+	}
+
+	&.reacted, &.reacted:hover {
+		background: var(--accentedBg);
+		color: var(--accent);
+		box-shadow: 0 0 0 1px var(--accent) inset;
+
+		> .count {
+			color: var(--accent);
+		}
+
+		> .icon {
+			filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
+		}
+	}
+}
+
+.limitWidth {
+	max-width: 70px;
+	object-fit: contain;
+}
+
+.count {
+	font-size: 0.7em;
+	line-height: 42px;
+	margin: 0 0 0 4px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.vue b/packages/frontend-embed/src/components/EmReactionsViewer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..014dd1c935f8bc594aca398806dd751b2949204b
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionsViewer.vue
@@ -0,0 +1,104 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<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"/>
+	<slot v-if="hasMoreReactions" name="more"></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { inject, watch, ref } from 'vue';
+import XReaction from '@/components/EmReactionsViewer.reaction.vue';
+
+const props = withDefaults(defineProps<{
+	note: Misskey.entities.Note;
+	maxNumber?: number;
+}>(), {
+	maxNumber: Infinity,
+});
+
+const mock = inject<boolean>('mock', false);
+
+const emit = defineEmits<{
+	(ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
+}>();
+
+const initialReactions = new Set(Object.keys(props.note.reactions));
+
+const reactions = ref<[string, number][]>([]);
+const hasMoreReactions = ref(false);
+
+if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
+	reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
+}
+
+function onMockToggleReaction(emoji: string, count: number) {
+	if (!mock) return;
+
+	const i = reactions.value.findIndex((item) => item[0] === emoji);
+	if (i < 0) return;
+
+	emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1]));
+}
+
+watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
+	let newReactions: [string, number][] = [];
+	hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
+
+	for (let i = 0; i < reactions.value.length; i++) {
+		const reaction = reactions.value[i][0];
+		if (reaction in newSource && newSource[reaction] !== 0) {
+			reactions.value[i][1] = newSource[reaction];
+			newReactions.push(reactions.value[i]);
+		}
+	}
+
+	const newReactionsNames = newReactions.map(([x]) => x);
+	newReactions = [
+		...newReactions,
+		...Object.entries(newSource)
+			.sort(([, a], [, b]) => b - a)
+			.filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)),
+	];
+
+	newReactions = newReactions.slice(0, props.maxNumber);
+
+	if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) {
+		newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]);
+	}
+
+	reactions.value = newReactions;
+}, { immediate: true, deep: true });
+</script>
+
+<style lang="scss" module>
+.transition_x_move,
+.transition_x_enterActive,
+.transition_x_leaveActive {
+	transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_x_enterFrom,
+.transition_x_leaveTo {
+	opacity: 0;
+	transform: scale(0.7);
+}
+.transition_x_leaveActive {
+	position: absolute;
+}
+
+.root {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	margin: 4px -2px 0 -2px;
+
+	&:empty {
+		display: none;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue
new file mode 100644
index 0000000000000000000000000000000000000000..382e39e4928bfbe72e8d2d53b7a756ea5b7cb83a
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue
@@ -0,0 +1,113 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
+	<div>
+		<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+		<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
+		<EmA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+		<EmMfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+		<EmA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA>
+	</div>
+	<details v-if="note.files && note.files.length > 0">
+		<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
+		<EmMediaList :mediaList="note.files" :originalEntityUrl="`${url}/notes/${note.id}`"/>
+	</details>
+	<details v-if="note.poll">
+		<summary>{{ i18n.ts.poll }}</summary>
+		<EmPoll :noteId="note.id" :poll="note.poll"/>
+	</details>
+	<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
+		<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
+	</button>
+	<button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+		<span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+	</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import { i18n } from '@/i18n.js';
+import { url } from '@/config.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+	note: Misskey.entities.Note;
+}>();
+
+const isLong = shouldCollapsed(props.note, []);
+
+const collapsed = ref(isLong);
+</script>
+
+<style lang="scss" module>
+.root {
+	overflow-wrap: break-word;
+
+	&.collapsed {
+		position: relative;
+		max-height: 9em;
+		overflow: clip;
+
+		> .fade {
+			display: block;
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
+
+			> .fadeLabel {
+				display: inline-block;
+				background: var(--panel);
+				padding: 6px 10px;
+				font-size: 0.8em;
+				border-radius: 999px;
+				box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+			}
+
+			&:hover {
+				> .fadeLabel {
+					background: var(--panelHighlight);
+				}
+			}
+		}
+	}
+}
+
+.reply {
+	margin-right: 6px;
+	color: var(--accent);
+}
+
+.rp {
+	margin-left: 4px;
+	font-style: oblique;
+	color: var(--renote);
+}
+
+.showLess {
+	width: 100%;
+	margin-top: 14px;
+	position: sticky;
+	bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+	display: inline-block;
+	background: var(--popup);
+	padding: 6px 10px;
+	font-size: 0.8em;
+	border-radius: 999px;
+	box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a8627e02c847091fdf41e19bffcd3a8ad22401b4
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmTime.vue
@@ -0,0 +1,107 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<time :title="absolute" :class="{ [$style.old1]: colored && (ago > 60 * 60 * 24 * 90), [$style.old2]: colored && (ago > 60 * 60 * 24 * 180) }">
+	<template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
+	<template v-else-if="mode === 'relative'">{{ relative }}</template>
+	<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
+	<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, computed } from 'vue';
+import { i18n } from '@/i18n.js';
+import { dateTimeFormat } from '@/to-be-shared/intl-const.js';
+
+const props = withDefaults(defineProps<{
+	time: Date | string | number | null;
+	origin?: Date | null;
+	mode?: 'relative' | 'absolute' | 'detail';
+	colored?: boolean;
+}>(), {
+	origin: null,
+	mode: 'relative',
+});
+
+function getDateSafe(n: Date | string | number) {
+	try {
+		if (n instanceof Date) {
+			return n;
+		}
+		return new Date(n);
+	} catch (err) {
+		return {
+			getTime: () => NaN,
+		};
+	}
+}
+
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
+const invalid = Number.isNaN(_time);
+const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
+
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const now = ref(props.origin?.getTime() ?? Date.now());
+const ago = computed(() => (now.value - _time) / 1000/*ms*/);
+
+const relative = computed<string>(() => {
+	if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
+	if (invalid) return i18n.ts._ago.invalid;
+
+	return (
+		ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) :
+		ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) :
+		ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) :
+		ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) :
+		ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) :
+		ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) :
+		ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) :
+		ago.value >= -3 ? i18n.ts._ago.justNow :
+		ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) :
+		ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) :
+		ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) :
+		ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) :
+		ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) :
+		ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) :
+		i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
+	);
+});
+
+let tickId: number;
+let currentInterval: number;
+
+function tick() {
+	now.value = Date.now();
+	const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000;
+
+	if (currentInterval !== nextInterval) {
+		if (tickId) window.clearInterval(tickId);
+		currentInterval = nextInterval;
+		tickId = window.setInterval(tick, nextInterval);
+	}
+}
+
+if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
+	onMounted(() => {
+		tick();
+	});
+	onUnmounted(() => {
+		if (tickId) window.clearInterval(tickId);
+	});
+}
+</script>
+
+<style lang="scss" module>
+.old1 {
+	color: var(--warn);
+}
+
+.old1.old2 {
+	color: var(--error);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c30b1102d9ed2decdb7bbd84390be9b8fe263f7
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue
@@ -0,0 +1,39 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.timelineRoot">
+	<div v-if="showHeader" :class="$style.header"><slot name="header"></slot></div>
+	<div :class="$style.body"><slot name="body"></slot></div>
+</div>
+</template>
+
+<script setup lang="ts">
+withDefaults(defineProps<{
+	showHeader?: boolean;
+}>(), {
+	showHeader: true,
+});
+</script>
+
+<style module lang="scss">
+.timelineRoot {
+	background-color: var(--panel);
+	height: 100%;
+	max-height: var(--embedMaxHeight, none);
+	display: flex;
+	flex-direction: column;
+}
+
+.header {
+	flex-shrink: 0;
+	border-bottom: 1px solid var(--divider);
+}
+
+.body {
+	flex-grow: 1;
+	overflow-y: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmUrl.vue b/packages/frontend-embed/src/components/EmUrl.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a96bfdb49311b61666a1d7404a187ed651b37150
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmUrl.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component
+	:is="self ? EmA : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
+	@contextmenu.stop="() => {}"
+>
+	<template v-if="!self">
+		<span :class="$style.schema">{{ schema }}//</span>
+		<span :class="$style.hostname">{{ hostname }}</span>
+		<span v-if="port != ''">:{{ port }}</span>
+	</template>
+	<template v-if="pathname === '/' && self">
+		<span :class="$style.self">{{ hostname }}</span>
+	</template>
+	<span v-if="pathname != ''" :class="$style.pathname">{{ self ? pathname.substring(1) : pathname }}</span>
+	<span :class="$style.query">{{ query }}</span>
+	<span :class="$style.hash">{{ hash }}</span>
+	<i v-if="target === '_blank'" :class="$style.icon" class="ti ti-external-link"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import EmA from './EmA.vue';
+import { url as local } from '@/config.js';
+
+function safeURIDecode(str: string): string {
+	try {
+		return decodeURIComponent(str);
+	} catch {
+		return str;
+	}
+}
+
+const props = withDefaults(defineProps<{
+	url: string;
+	rel?: string;
+	showUrlPreview?: boolean;
+}>(), {
+	showUrlPreview: true,
+});
+
+const self = props.url.startsWith(local);
+const url = new URL(props.url);
+if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
+const el = ref();
+
+const schema = url.protocol;
+const hostname = decodePunycode(url.hostname);
+const port = url.port;
+const pathname = safeURIDecode(url.pathname);
+const query = safeURIDecode(url.search);
+const hash = safeURIDecode(url.hash);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+</script>
+
+<style lang="scss" module>
+.root {
+	word-break: break-all;
+}
+
+.icon {
+	padding-left: 2px;
+	font-size: .9em;
+}
+
+.self {
+	font-weight: bold;
+}
+
+.schema {
+	opacity: 0.5;
+}
+
+.hostname {
+	font-weight: bold;
+}
+
+.pathname {
+	opacity: 0.8;
+}
+
+.query {
+	opacity: 0.5;
+}
+
+.hash {
+	font-style: italic;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmUserName.vue b/packages/frontend-embed/src/components/EmUserName.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c0c7c443caebb7045ad59c792aacb31dce2a17ca
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmUserName.vue
@@ -0,0 +1,21 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmMfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emojiUrls="user.emojis"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmMfm from './EmMfm.js';
+
+const props = withDefaults(defineProps<{
+	user: Misskey.entities.User;
+	nowrap?: boolean;
+}>(), {
+	nowrap: true,
+});
+</script>
diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b621110ec9ede8f68dc5df979627d27036176dd2
--- /dev/null
+++ b/packages/frontend-embed/src/components/I18n.vue
@@ -0,0 +1,51 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<render/>
+</template>
+
+<script setup lang="ts" generic="T extends string | ParameterizedString">
+import { computed, h } from 'vue';
+import type { ParameterizedString } from '../../../../locales/index.js';
+
+const props = withDefaults(defineProps<{
+	src: T;
+	tag?: string;
+	// eslint-disable-next-line vue/require-default-prop
+	textTag?: string;
+}>(), {
+	tag: 'span',
+});
+
+const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>();
+
+const parsed = computed(() => {
+	let str = props.src as string;
+	const value: (string | { arg: string; })[] = [];
+	for (;;) {
+		const nextBracketOpen = str.indexOf('{');
+		const nextBracketClose = str.indexOf('}');
+
+		if (nextBracketOpen === -1) {
+			value.push(str);
+			break;
+		} else {
+			if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen));
+			value.push({
+				arg: str.substring(nextBracketOpen + 1, nextBracketClose),
+			});
+		}
+
+		str = str.substring(nextBracketClose + 1);
+	}
+
+	return value;
+});
+
+const render = () => {
+	return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
+};
+</script>
diff --git a/packages/frontend-embed/src/config.ts b/packages/frontend-embed/src/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f9850ba461a67dd682766dab4e3663fdc2020ba9
--- /dev/null
+++ b/packages/frontend-embed/src/config.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
+const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
+
+export const host = address.host;
+export const hostname = address.hostname;
+export const url = address.origin;
+export const apiUrl = location.origin + '/api';
+export const lang = localStorage.getItem('lang') ?? 'en-US';
+export const langs = _LANGS_;
+const preParseLocale = localStorage.getItem('locale');
+export const locale = preParseLocale ? JSON.parse(preParseLocale) : null;
+export const instanceName = siteName === 'Misskey' || siteName == null ? host : siteName;
+export const debug = localStorage.getItem('debug') === 'true';
diff --git a/packages/frontend-embed/src/custom-emojis.ts b/packages/frontend-embed/src/custom-emojis.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d5b40885c1d441064ea410859ce5af8b526a3f33
--- /dev/null
+++ b/packages/frontend-embed/src/custom-emojis.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { shallowRef, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import { misskeyApi, misskeyApiGet } from '@/misskey-api.js';
+
+function get(key: string) {
+	const value = localStorage.getItem(key);
+	if (value === null) return null;
+	return JSON.parse(value);
+}
+
+function set(key: string, value: any) {
+	localStorage.setItem(key, JSON.stringify(value));
+}
+
+const storageCache = await get('emojis');
+export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []);
+
+export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>();
+watch(customEmojis, emojis => {
+	customEmojisMap.clear();
+	for (const emoji of emojis) {
+		customEmojisMap.set(emoji.name, emoji);
+	}
+}, { immediate: true });
+
+export async function fetchCustomEmojis(force = false) {
+	const now = Date.now();
+
+	let res;
+	if (force) {
+		res = await misskeyApi('emojis', {});
+	} else {
+		const lastFetchedAt = await get('lastEmojisFetchedAt');
+		if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return;
+		res = await misskeyApiGet('emojis', {});
+	}
+
+	customEmojis.value = res.emojis;
+	set('emojis', res.emojis);
+	set('lastEmojisFetchedAt', now);
+}
+
+let cachedTags;
+export function getCustomEmojiTags() {
+	if (cachedTags) return cachedTags;
+
+	const tags = new Set();
+	for (const emoji of customEmojis.value) {
+		for (const tag of emoji.aliases) {
+			tags.add(tag);
+		}
+	}
+	const res = Array.from(tags);
+	cachedTags = res;
+	return res;
+}
diff --git a/packages/frontend-embed/src/di.ts b/packages/frontend-embed/src/di.ts
new file mode 100644
index 0000000000000000000000000000000000000000..799bbed598a1abf56ab87b750b09b4f0af841ee1
--- /dev/null
+++ b/packages/frontend-embed/src/di.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { InjectionKey } from 'vue';
+import * as Misskey from 'misskey-js';
+import { MediaProxy } from '@@/js/media-proxy.js';
+import type { ParsedEmbedParams } from '@@/js/embed-page.js';
+
+export const DI = {
+	serverMetadata: Symbol() as InjectionKey<Misskey.entities.MetaDetailed>,
+	embedParams: Symbol() as InjectionKey<ParsedEmbedParams>,
+	mediaProxy: Symbol() as InjectionKey<MediaProxy>,
+};
diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts
new file mode 100644
index 0000000000000000000000000000000000000000..17e787f9fc17df7e6d481466686b5be930cead0a
--- /dev/null
+++ b/packages/frontend-embed/src/i18n.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { markRaw } from 'vue';
+import { I18n } from '@@/js/i18n.js';
+import type { Locale } from '../../../locales/index.js';
+import { locale } from '@/config.js';
+
+export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
+
+export function updateI18n(newLocale: Locale) {
+	i18n.locale = newLocale;
+}
diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..47b0b0e84e5ffec78da1aed4a2fa1001d15a09b6
--- /dev/null
+++ b/packages/frontend-embed/src/index.html
@@ -0,0 +1,36 @@
+<!--
+  SPDX-FileCopyrightText: syuilo and misskey-project
+  SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<!--
+  開発モードのviteはこのファイルを起点にサーバーを起動します。
+  このファイルに書かれた [t]js のリンクと (s)cssのリンクと、その依存関係にあるファイルはビルドされます
+-->
+
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="UTF-8" />
+	<title>[DEV] Loading...</title>
+	<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
+	<meta
+		http-equiv="Content-Security-Policy"
+		content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
+			worker-src 'self';
+			script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
+			style-src 'self' 'unsafe-inline';
+			img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
+			media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
+			connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
+			frame-src *;"
+	/>
+	<meta property="og:site_name" content="[DEV BUILD] Misskey" />
+	<meta name="viewport" content="width=device-width, initial-scale=1">
+</head>
+
+<body>
+<div id="misskey_app"></div>
+<script type="module" src="./boot.ts"></script>
+</body>
+</html>
diff --git a/packages/frontend-embed/src/misskey-api.ts b/packages/frontend-embed/src/misskey-api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..13630590b674e78da83c7814e9a172e522f69480
--- /dev/null
+++ b/packages/frontend-embed/src/misskey-api.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 '@/config.js';
+
+export const pendingApiRequestsCount = ref(0);
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApi<
+	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,
+	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) => {
+		// 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-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6564eecd7584119ed6da5cf626e6f0daf05d3344
--- /dev/null
+++ b/packages/frontend-embed/src/pages/clip.vue
@@ -0,0 +1,140 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<MkLoading v-if="loading"/>
+	<EmTimelineContainer v-else-if="clip" :showHeader="embedParams.header">
+		<template #header>
+			<div :class="$style.clipHeader">
+				<div :class="$style.headerClipIconRoot">
+					<i class="ti ti-paperclip"></i>
+				</div>
+				<div :class="$style.headerTitle" @click="top">
+					<div class="_nowrap"><a :href="`/clips/${clip.id}`" target="_blank" rel="noopener">{{ clip.name }}</a></div>
+					<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+				</div>
+				<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+					<img
+						:class="$style.instanceIcon"
+						:src="serverMetadata.iconUrl || '/favicon.ico'"
+					/>
+				</a>
+			</div>
+		</template>
+		<template #body>
+			<EmNotes
+				ref="notesEl"
+				:pagination="pagination"
+				:disableAutoLoad="!embedParams.autoload"
+				:noGap="true"
+				:ad="false"
+			/>
+		</template>
+	</EmTimelineContainer>
+	<XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, shallowRef, inject } from 'vue';
+import * as Misskey from 'misskey-js';
+import { scrollToTop } from '@@/js/scroll.js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { isLink } from '@/to-be-shared/is-link.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import { DI } from '@/di.js';
+
+const props = defineProps<{
+	clipId: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+const clip = ref<Misskey.entities.Clip | null>(null);
+const pagination = computed(() => ({
+	endpoint: 'clips/notes',
+	params: {
+		clipId: props.clipId,
+	},
+} as Paging));
+const loading = ref(true);
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+function top(ev: MouseEvent) {
+	const target = ev.target as HTMLElement | null;
+	if (target && isLink(target)) return;
+
+	if (notesEl.value) {
+		scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
+	}
+}
+
+misskeyApi('clips/show', {
+	clipId: props.clipId,
+}).then(res => {
+	clip.value = res;
+	loading.value = false;
+}).catch(err => {
+	console.error(err);
+	loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.clipHeader {
+	padding: 8px 16px;
+	display: flex;
+	min-width: 0;
+	align-items: center;
+	gap: var(--margin);
+	overflow: hidden;
+
+	.headerClipIconRoot {
+		flex-shrink: 0;
+		width: 32px;
+		height: 32px;
+		line-height: 32px;
+		font-size: 14px;
+		text-align: center;
+		background-color: var(--accentedBg);
+		color: var(--accent);
+		border-radius: 50%;
+	}
+
+	.headerTitle {
+		flex-grow: 1;
+		font-weight: 700;
+		line-height: 1.1;
+		min-width: 0;
+
+		.sub {
+			font-size: 0.8em;
+			font-weight: 400;
+			opacity: 0.7;
+		}
+	}
+
+	.instanceIconLink {
+		flex-shrink: 0;
+		display: block;
+		margin-left: auto;
+		height: 24px;
+	}
+
+	.instanceIcon {
+		height: 24px;
+		border-radius: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bbb03b4e642d25bf820adc01aa7d43c66ba46bc3
--- /dev/null
+++ b/packages/frontend-embed/src/pages/not-found.vue
@@ -0,0 +1,24 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<div class="_fullinfo">
+		<img :src="notFoundImageUrl" class="_ghost"/>
+		<div>{{ i18n.ts.notFoundDescription }}</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, computed } from 'vue';
+import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js';
+import { DI } from '@/di.js';
+import { i18n } from '@/i18n.js';
+
+const serverMetadata = inject(DI.serverMetadata)!;
+
+const notFoundImageUrl = computed(() => serverMetadata?.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
+</script>
diff --git a/packages/frontend-embed/src/pages/note.vue b/packages/frontend-embed/src/pages/note.vue
new file mode 100644
index 0000000000000000000000000000000000000000..86aebe072a12699f164803d621f45c3bd6967444
--- /dev/null
+++ b/packages/frontend-embed/src/pages/note.vue
@@ -0,0 +1,48 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.noteEmbedRoot">
+	<EmLoading v-if="loading"/>
+	<EmNoteDetailed v-else-if="note" :note="note"/>
+	<XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmNoteDetailed from '@/components/EmNoteDetailed.vue';
+import EmLoading from '@/components/EmLoading.vue';
+import XNotFound from '@/pages/not-found.vue';
+import { misskeyApi } from '@/misskey-api.js';
+
+const props = defineProps<{
+	noteId: string;
+}>();
+
+const note = ref<Misskey.entities.Note | null>(null);
+const loading = ref(true);
+
+// TODO: クライアント側でAPIを叩くのは二度手間なので予めHTMLに埋め込んでおく
+misskeyApi('notes/show', {
+	noteId: props.noteId,
+}).then(res => {
+	// リモートのノートは埋め込ませない
+	if (res.url == null && res.uri == null) {
+		note.value = res;
+	}
+	loading.value = false;
+}).catch(err => {
+	console.error(err);
+	loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.noteEmbedRoot {
+	background-color: var(--panel);
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d69555287adb935f108a3d93b95031db0d241aea
--- /dev/null
+++ b/packages/frontend-embed/src/pages/tag.vue
@@ -0,0 +1,125 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<EmTimelineContainer v-if="tag" :showHeader="embedParams.header">
+		<template #header>
+			<div :class="$style.clipHeader">
+				<div :class="$style.headerClipIconRoot">
+					<i class="ti ti-hash"></i>
+				</div>
+				<div :class="$style.headerTitle" @click="top">
+					<div class="_nowrap"><a :href="`/tags/${tag}`" target="_blank" rel="noopener">#{{ tag }}</a></div>
+					<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+				</div>
+				<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+					<img
+						:class="$style.instanceIcon"
+						:src="serverMetadata.iconUrl || '/favicon.ico'"
+					/>
+				</a>
+			</div>
+		</template>
+		<template #body>
+			<EmNotes
+				ref="notesEl"
+				:pagination="pagination"
+				:disableAutoLoad="!embedParams.autoload"
+				:noGap="true"
+				:ad="false"
+			/>
+		</template>
+	</EmTimelineContainer>
+	<XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, shallowRef, inject } from 'vue';
+import { scrollToTop } from '@@/js/scroll.js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { isLink } from '@/to-be-shared/is-link.js';
+import { DI } from '@/di.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+
+const props = defineProps<{
+	tag: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+const pagination = computed(() => ({
+	endpoint: 'notes/search-by-tag',
+	params: {
+		tag: props.tag,
+	},
+} as Paging));
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+function top(ev: MouseEvent) {
+	const target = ev.target as HTMLElement | null;
+	if (target && isLink(target)) return;
+
+	if (notesEl.value) {
+		scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
+	}
+}
+</script>
+
+<style lang="scss" module>
+.clipHeader {
+	padding: 8px 16px;
+	display: flex;
+	min-width: 0;
+	align-items: center;
+	gap: var(--margin);
+	overflow: hidden;
+
+	.headerClipIconRoot {
+		flex-shrink: 0;
+		width: 32px;
+		height: 32px;
+		line-height: 32px;
+		font-size: 14px;
+		text-align: center;
+		background-color: var(--accentedBg);
+		color: var(--accent);
+		border-radius: 50%;
+	}
+
+	.headerTitle {
+		flex-grow: 1;
+		font-weight: 700;
+		line-height: 1.1;
+		min-width: 0;
+
+		.sub {
+			font-size: 0.8em;
+			font-weight: 400;
+			opacity: 0.7;
+		}
+	}
+
+	.instanceIconLink {
+		flex-shrink: 0;
+		display: block;
+		margin-left: auto;
+		height: 24px;
+	}
+
+	.instanceIcon {
+		height: 24px;
+		border-radius: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d590f6e65085d0adf2828e2471ebe4f591022695
--- /dev/null
+++ b/packages/frontend-embed/src/pages/user-timeline.vue
@@ -0,0 +1,138 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+	<EmLoading v-if="loading"/>
+	<EmTimelineContainer v-else-if="user" :showHeader="embedParams.header">
+		<template #header>
+			<div :class="$style.userHeader">
+				<a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink">
+					<EmAvatar :class="$style.avatar" :user="user"/>
+				</a>
+				<div :class="$style.headerTitle">
+					<I18n :src="i18n.ts.noteOf" tag="div" class="_nowrap">
+						<template #user>
+							<a v-if="user != null" :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer">
+								<EmUserName :user="user"/>
+							</a>
+							<span v-else>{{ i18n.ts.user }}</span>
+						</template>
+					</I18n>
+					<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+				</div>
+				<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+					<img
+						:class="$style.instanceIcon"
+						:src="serverMetadata.iconUrl || '/favicon.ico'"
+					/>
+				</a>
+			</div>
+		</template>
+		<template #body>
+			<EmNotes
+				ref="notesEl"
+				:pagination="pagination"
+				:disableAutoLoad="!embedParams.autoload"
+				:noGap="true"
+				:ad="false"
+			/>
+		</template>
+	</EmTimelineContainer>
+	<XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, shallowRef, inject } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmLoading from '@/components/EmLoading.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import I18n from '@/components/I18n.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import { DI } from '@/di.js';
+
+const props = defineProps<{
+	userId: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+const user = ref<Misskey.entities.UserLite | null>(null);
+const pagination = computed(() => ({
+	endpoint: 'users/notes',
+	params: {
+		userId: user.value?.id,
+	},
+} as Paging));
+const loading = ref(true);
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+misskeyApi('users/show', {
+	userId: props.userId,
+}).then(res => {
+	user.value = res;
+	loading.value = false;
+}).catch(err => {
+	console.error(err);
+	loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.userHeader {
+	padding: 8px 16px;
+	display: flex;
+	min-width: 0;
+	align-items: center;
+	gap: var(--margin);
+	overflow: hidden;
+
+	.avatarLink {
+		display: block;
+	}
+
+	.avatar {
+		display: inline-block;
+		width: 32px;
+		height: 32px;
+	}
+
+	.headerTitle {
+		flex-grow: 1;
+		font-weight: 700;
+		line-height: 1.1;
+		min-width: 0;
+
+		.sub {
+			font-size: 0.8em;
+			font-weight: 400;
+			opacity: 0.7;
+		}
+	}
+
+	.instanceIconLink {
+		flex-shrink: 0;
+		display: block;
+		margin-left: auto;
+		height: 24px;
+	}
+
+	.instanceIcon {
+		height: 24px;
+		border-radius: 4px;
+	}
+}
+</style>
diff --git a/packages/frontend-embed/src/post-message.ts b/packages/frontend-embed/src/post-message.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fd8eb8a5d2a094b3c76187db48299b44e56aa41f
--- /dev/null
+++ b/packages/frontend-embed/src/post-message.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const postMessageEventTypes = [
+	'misskey:embed:ready',
+	'misskey:embed:changeHeight',
+] as const;
+
+export type PostMessageEventType = typeof postMessageEventTypes[number];
+
+export interface PostMessageEventPayload extends Record<PostMessageEventType, any> {
+	'misskey:embed:ready': undefined;
+	'misskey:embed:changeHeight': {
+		height: number;
+	};
+}
+
+export type MiPostMessageEvent<T extends PostMessageEventType = PostMessageEventType> = {
+	type: T;
+	iframeId?: string;
+	payload?: PostMessageEventPayload[T];
+}
+
+let defaultIframeId: string | null = null;
+
+export function setIframeId(id: string): void {
+	if (defaultIframeId != null) return;
+
+	if (_DEV_) console.log('setIframeId', id);
+	defaultIframeId = id;
+}
+
+/**
+ * 親フレームにイベントを送信
+ */
+export function postMessageToParentWindow<T extends PostMessageEventType = PostMessageEventType>(type: T, payload?: PostMessageEventPayload[T], iframeId: string | null = null): void {
+	let _iframeId = iframeId;
+	if (_iframeId == null) {
+		_iframeId = defaultIframeId;
+	}
+	if (_DEV_) console.log('postMessageToParentWindow', type, _iframeId, payload);
+	window.parent.postMessage({
+		type,
+		iframeId: _iframeId,
+		payload,
+	}, '*');
+}
diff --git a/packages/frontend-embed/src/server-metadata.ts b/packages/frontend-embed/src/server-metadata.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2bd57a0990c50c1dc50cb9321fe0d3e9badf6d17
--- /dev/null
+++ b/packages/frontend-embed/src/server-metadata.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { misskeyApi } from '@/misskey-api.js';
+
+const providedMetaEl = document.getElementById('misskey_meta');
+
+const _serverMetadata = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;
+
+// NOTE: devモードのときしか _serverMetadata が null になることは無い
+export const serverMetadata = _serverMetadata ?? await misskeyApi('meta', {
+	detail: true,
+});
diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss
new file mode 100644
index 0000000000000000000000000000000000000000..02008ddbd05b09ea661e010a8e632ac319e5b2bf
--- /dev/null
+++ b/packages/frontend-embed/src/style.scss
@@ -0,0 +1,453 @@
+@charset "utf-8";
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+:root {
+	--radius: 12px;
+	--marginFull: 14px;
+	--marginHalf: 10px;
+
+	--margin: var(--marginFull);
+}
+
+html {
+	background-color: transparent;
+	color-scheme: light dark;
+	color: var(--fg);
+	accent-color: var(--accent);
+	overflow: clip;
+	overflow-wrap: break-word;
+	font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+	font-size: 14px;
+	line-height: 1.35;
+	text-size-adjust: 100%;
+	tab-size: 2;
+	-webkit-text-size-adjust: 100%;
+
+	&, * {
+		scrollbar-color: var(--scrollbarHandle) transparent;
+		scrollbar-width: thin;
+
+		&::-webkit-scrollbar {
+			width: 6px;
+			height: 6px;
+		}
+
+		&::-webkit-scrollbar-track {
+			background: inherit;
+		}
+
+		&::-webkit-scrollbar-thumb {
+			background: var(--scrollbarHandle);
+
+			&:hover {
+				background: var(--scrollbarHandleHover);
+			}
+
+			&:active {
+				background: var(--accent);
+			}
+		}
+	}
+}
+
+html, body {
+	height: 100%;
+	touch-action: manipulation;
+	margin: 0;
+	padding: 0;
+	scroll-behavior: smooth;
+}
+
+#misskey_app {
+	height: 100%;
+}
+
+a {
+	text-decoration: none;
+	cursor: pointer;
+	color: inherit;
+	tap-highlight-color: transparent;
+	-webkit-tap-highlight-color: transparent;
+	-webkit-touch-callout: none;
+
+	&:focus-visible {
+		outline-offset: 2px;
+	}
+
+	&:hover {
+		text-decoration: underline;
+	}
+
+	&[target="_blank"] {
+		-webkit-touch-callout: default;
+	}
+}
+
+rt {
+	white-space: initial;
+}
+
+:focus-visible {
+	outline: var(--focus) solid 2px;
+	outline-offset: -2px;
+
+	&:hover {
+		text-decoration: none;
+	}
+}
+
+.ti {
+	width: 1.28em;
+	vertical-align: -12%;
+	line-height: 1em;
+
+	&::before {
+		font-size: 128%;
+	}
+}
+
+.ti-fw {
+	display: inline-block;
+	text-align: center;
+}
+
+._nowrap {
+	white-space: pre !important;
+	word-wrap: normal !important; // https://codeday.me/jp/qa/20190424/690106.html
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+._button {
+	user-select: none;
+	-webkit-user-select: none;
+	-webkit-touch-callout: none;
+	appearance: none;
+	display: inline-block;
+	padding: 0;
+	margin: 0; // for Safari
+	background: none;
+	border: none;
+	cursor: pointer;
+	color: inherit;
+	touch-action: manipulation;
+	tap-highlight-color: transparent;
+	-webkit-tap-highlight-color: transparent;
+	font-size: 1em;
+	font-family: inherit;
+	line-height: inherit;
+	max-width: 100%;
+
+	&:disabled {
+		opacity: 0.5;
+		cursor: default;
+	}
+}
+
+._buttonGray {
+	@extend ._button;
+	background: var(--buttonBg);
+
+	&:not(:disabled):hover {
+		background: var(--buttonHoverBg);
+	}
+}
+
+._buttonPrimary {
+	@extend ._button;
+	color: var(--fgOnAccent);
+	background: var(--accent);
+
+	&:not(:disabled):hover {
+		background: hsl(from var(--accent) h s calc(l + 5));
+	}
+
+	&:not(:disabled):active {
+		background: hsl(from var(--accent) h s calc(l - 5));
+	}
+}
+
+._buttonGradate {
+	@extend ._buttonPrimary;
+	color: var(--fgOnAccent);
+	background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+
+	&:not(:disabled):hover {
+		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
+	}
+
+	&:not(:disabled):active {
+		background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
+	}
+}
+
+._buttonRounded {
+	font-size: 0.95em;
+	padding: 0.5em 1em;
+	min-width: 100px;
+	border-radius: 99rem;
+
+	&._buttonPrimary,
+	&._buttonGradate {
+		font-weight: 700;
+	}
+}
+
+._help {
+	color: var(--accent);
+	cursor: help;
+}
+
+._textButton {
+	@extend ._button;
+	color: var(--accent);
+
+	&:focus-visible {
+		outline-offset: 2px;
+	}
+
+	&:not(:disabled):hover {
+		text-decoration: underline;
+	}
+}
+
+._panel {
+	background: var(--panel);
+	border-radius: var(--radius);
+	overflow: clip;
+}
+
+._margin {
+	margin: var(--margin) 0;
+}
+
+._gaps_m {
+	display: flex;
+	flex-direction: column;
+	gap: 1.5em;
+}
+
+._gaps_s {
+	display: flex;
+	flex-direction: column;
+	gap: 0.75em;
+}
+
+._gaps {
+	display: flex;
+	flex-direction: column;
+	gap: var(--margin);
+}
+
+._buttons {
+	display: flex;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+
+._buttonsCenter {
+	@extend ._buttons;
+
+	justify-content: center;
+}
+
+._borderButton {
+	@extend ._button;
+	display: block;
+	width: 100%;
+	padding: 10px;
+	box-sizing: border-box;
+	text-align: center;
+	border: solid 0.5px var(--divider);
+	border-radius: var(--radius);
+
+	&:active {
+		border-color: var(--accent);
+	}
+}
+
+._popup {
+	background: var(--popup);
+	border-radius: var(--radius);
+	contain: content;
+}
+
+._acrylic {
+	background: var(--acrylicPanel);
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+}
+
+._fullinfo {
+	padding: 64px 32px;
+	text-align: center;
+
+	> img {
+		vertical-align: bottom;
+		height: 128px;
+		margin-bottom: 16px;
+		border-radius: 16px;
+	}
+}
+
+._link {
+	color: var(--link);
+}
+
+._caption {
+	font-size: 0.8em;
+	opacity: 0.7;
+}
+
+._monospace {
+	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
+}
+
+// MFM -----------------------------
+
+._mfm_blur_ {
+	filter: blur(6px);
+	transition: filter 0.3s;
+
+	&:hover {
+		filter: blur(0px);
+	}
+}
+
+.mfm-x2 {
+	--mfm-zoom-size: 200%;
+}
+
+.mfm-x3 {
+	--mfm-zoom-size: 400%;
+}
+
+.mfm-x4 {
+	--mfm-zoom-size: 600%;
+}
+
+.mfm-x2, .mfm-x3, .mfm-x4 {
+	font-size: var(--mfm-zoom-size);
+
+	.mfm-x2, .mfm-x3, .mfm-x4 {
+		/* only half effective */
+		font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
+
+		.mfm-x2, .mfm-x3, .mfm-x4 {
+			/* disabled */
+			font-size: 100%;
+		}
+	}
+}
+
+._mfm_rainbow_fallback_ {
+	background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%);
+	-webkit-background-clip: text;
+	background-clip: text;
+	color: transparent;
+}
+
+@keyframes mfm-spin {
+	0% { transform: rotate(0deg); }
+	100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+	0% { transform: perspective(128px) rotateX(0deg); }
+	100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+	0% { transform: perspective(128px) rotateY(0deg); }
+	100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+	0% { transform: translateY(0); }
+	25% { transform: translateY(-16px); }
+	50% { transform: translateY(0); }
+	75% { transform: translateY(-8px); }
+	100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+	0% { transform: translateY(0) scale(1, 1); }
+	25% { transform: translateY(-16px) scale(1, 1); }
+	50% { transform: translateY(0) scale(1, 1); }
+	75% { transform: translateY(0) scale(1.5, 0.75); }
+	100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+	0% { transform: translate(7px, -2px) }
+	5% { transform: translate(-3px, 1px) }
+	10% { transform: translate(-7px, -1px) }
+	15% { transform: translate(0px, -1px) }
+	20% { transform: translate(-8px, 6px) }
+	25% { transform: translate(-4px, -3px) }
+	30% { transform: translate(-4px, -6px) }
+	35% { transform: translate(-8px, -8px) }
+	40% { transform: translate(4px, 6px) }
+	45% { transform: translate(-3px, 1px) }
+	50% { transform: translate(2px, -10px) }
+	55% { transform: translate(-7px, 0px) }
+	60% { transform: translate(-2px, 4px) }
+	65% { transform: translate(3px, -8px) }
+	70% { transform: translate(6px, 7px) }
+	75% { transform: translate(-7px, -2px) }
+	80% { transform: translate(-7px, -8px) }
+	85% { transform: translate(9px, 3px) }
+	90% { transform: translate(-3px, -2px) }
+	95% { transform: translate(-10px, 2px) }
+	100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+	0% { transform: translate(-3px, -1px) rotate(-8deg) }
+	5% { transform: translate(0px, -1px) rotate(-10deg) }
+	10% { transform: translate(1px, -3px) rotate(0deg) }
+	15% { transform: translate(1px, 1px) rotate(11deg) }
+	20% { transform: translate(-2px, 1px) rotate(1deg) }
+	25% { transform: translate(-1px, -2px) rotate(-2deg) }
+	30% { transform: translate(-1px, 2px) rotate(-3deg) }
+	35% { transform: translate(2px, 1px) rotate(6deg) }
+	40% { transform: translate(-2px, -3px) rotate(-9deg) }
+	45% { transform: translate(0px, -1px) rotate(-12deg) }
+	50% { transform: translate(1px, 2px) rotate(10deg) }
+	55% { transform: translate(0px, -3px) rotate(8deg) }
+	60% { transform: translate(1px, -1px) rotate(8deg) }
+	65% { transform: translate(0px, -1px) rotate(-7deg) }
+	70% { transform: translate(-1px, -3px) rotate(6deg) }
+	75% { transform: translate(0px, -2px) rotate(4deg) }
+	80% { transform: translate(-2px, -1px) rotate(3deg) }
+	85% { transform: translate(1px, -3px) rotate(-10deg) }
+	90% { transform: translate(1px, 0px) rotate(3deg) }
+	95% { transform: translate(-2px, 0px) rotate(-3deg) }
+	100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+	from { transform: scale3d(1, 1, 1); }
+	30% { transform: scale3d(1.25, 0.75, 1); }
+	40% { transform: scale3d(0.75, 1.25, 1); }
+	50% { transform: scale3d(1.15, 0.85, 1); }
+	65% { transform: scale3d(0.95, 1.05, 1); }
+	75% { transform: scale3d(1.05, 0.95, 1); }
+	to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+	0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+	100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts
new file mode 100644
index 0000000000000000000000000000000000000000..050d8cf63ba4c7dc77d88c2f192ebcad57426dd5
--- /dev/null
+++ b/packages/frontend-embed/src/theme.ts
@@ -0,0 +1,102 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
+import type { BundledTheme } from 'shiki/themes';
+
+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>;
+	};
+};
+
+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';
+
+	// Deep copy
+	const _theme = JSON.parse(JSON.stringify(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(`--${k}`, v.toString());
+	}
+
+	document.documentElement.style.setProperty('color-scheme', colorScheme);
+}
+
+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();
+}
diff --git a/packages/frontend-embed/src/to-be-shared/collapsed.ts b/packages/frontend-embed/src/to-be-shared/collapsed.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4ec88a3c657329bc4533be4b7db2e060a38bdb18
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/collapsed.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+
+export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
+	const collapsed = note.cw == null && (
+		note.text != null && (
+			(note.text.includes('$[x2')) ||
+			(note.text.includes('$[x3')) ||
+			(note.text.includes('$[x4')) ||
+			(note.text.includes('$[scale')) ||
+			(note.text.split('\n').length > 9) ||
+			(note.text.length > 500) ||
+			(urls.length >= 4)
+		) || note.files.length >= 5
+	);
+
+	return collapsed;
+}
diff --git a/packages/frontend-embed/src/to-be-shared/intl-const.ts b/packages/frontend-embed/src/to-be-shared/intl-const.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aaa4f0a86edf4898662d1a3f6caeaea88f057c4c
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/intl-const.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@/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-embed/src/to-be-shared/is-link.ts b/packages/frontend-embed/src/to-be-shared/is-link.ts
new file mode 100644
index 0000000000000000000000000000000000000000..946f86400e16e75b4f4e73b460b98006363be0ec
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/is-link.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isLink(el: HTMLElement) {
+	if (el.tagName === 'A') return true;
+	if (el.parentElement) {
+		return isLink(el.parentElement);
+	}
+	return false;
+}
diff --git a/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6b3fcd938334d03595e57508d2dfe6aeada60085
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
+	return prev + 1;
+}
+
+export class WorkerMultiDispatch<POST = any, RETURN = any> {
+	private symbol = Symbol('WorkerMultiDispatch');
+	private workers: Worker[] = [];
+	private terminated = false;
+	private prevWorkerNumber = 0;
+	private getUseWorkerNumber = defaultUseWorkerNumber;
+	private finalizationRegistry: FinalizationRegistry<symbol>;
+
+	constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
+		this.getUseWorkerNumber = getUseWorkerNumber;
+		for (let i = 0; i < concurrency; i++) {
+			this.workers.push(workerConstructor());
+		}
+
+		this.finalizationRegistry = new FinalizationRegistry(() => {
+			this.terminate();
+		});
+		this.finalizationRegistry.register(this, this.symbol);
+
+		if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
+	}
+
+	public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
+		let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
+		workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
+		if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
+		this.prevWorkerNumber = workerNumber;
+
+		// 不毛だがunionをoverloadに突っ込めない
+		// https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
+		// https://github.com/microsoft/TypeScript/issues/14107
+		if (Array.isArray(options)) {
+			this.workers[workerNumber].postMessage(message, options);
+		} else {
+			this.workers[workerNumber].postMessage(message, options);
+		}
+		return workerNumber;
+	}
+
+	public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+		this.workers.forEach(worker => {
+			worker.addEventListener('message', callback, options);
+		});
+	}
+
+	public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+		this.workers.forEach(worker => {
+			worker.removeEventListener('message', callback, options);
+		});
+	}
+
+	public terminate() {
+		this.terminated = true;
+		if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
+		this.workers.forEach(worker => {
+			worker.terminate();
+		});
+		this.workers = [];
+		this.finalizationRegistry.unregister(this);
+	}
+
+	public isTerminated() {
+		return this.terminated;
+	}
+
+	public getWorkers() {
+		return this.workers;
+	}
+
+	public getSymbol() {
+		return this.symbol;
+	}
+}
diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3b8449dac84c9e35c7b3ad3adee0ef3e38d4787e
--- /dev/null
+++ b/packages/frontend-embed/src/ui.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	ref="rootEl"
+	:class="[
+		$style.rootForEmbedPage,
+		{
+			[$style.rounded]: embedRounded,
+			[$style.noBorder]: embedNoBorder,
+		}
+	]"
+	:style="maxHeight > 0 ? { maxHeight: `${maxHeight}px`, '--embedMaxHeight': `${maxHeight}px` } : {}"
+>
+	<div
+		:class="$style.routerViewContainer"
+	>
+		<EmNotePage v-if="page === 'notes'" :noteId="contentId"/>
+		<EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/>
+		<EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/>
+		<EmTagPage v-else-if="page === 'tags'" :tag="contentId"/>
+		<XNotFound v-else/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, onMounted, onUnmounted, inject } from 'vue';
+import { postMessageToParentWindow } from '@/post-message.js';
+import { DI } from '@/di.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import EmNotePage from '@/pages/note.vue';
+import EmUserTimelinePage from '@/pages/user-timeline.vue';
+import EmClipPage from '@/pages/clip.vue';
+import EmTagPage from '@/pages/tag.vue';
+import XNotFound from '@/pages/not-found.vue';
+
+const page = location.pathname.split('/')[2];
+const contentId = location.pathname.split('/')[3];
+console.log(page, contentId);
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+//#region Embed Style
+const embedRounded = ref(embedParams.rounded);
+const embedNoBorder = ref(!embedParams.border);
+const maxHeight = ref(embedParams.maxHeight ?? 0);
+//#endregion
+
+//#region Embed Resizer
+const rootEl = shallowRef<HTMLElement | null>(null);
+
+let previousHeight = 0;
+const resizeObserver = new ResizeObserver(async () => {
+	const height = rootEl.value!.scrollHeight + (embedNoBorder.value ? 0 : 2); // border 上下1px
+	if (Math.abs(previousHeight - height) < 1) return; // 1px未満の変化は無視
+	postMessageToParentWindow('misskey:embed:changeHeight', {
+		height: (maxHeight.value > 0 && height > maxHeight.value) ? maxHeight.value : height,
+	});
+	previousHeight = height;
+});
+onMounted(() => {
+	resizeObserver.observe(rootEl.value!);
+});
+onUnmounted(() => {
+	resizeObserver.disconnect();
+});
+//#endregion
+</script>
+
+<style lang="scss" module>
+.rootForEmbedPage {
+	box-sizing: border-box;
+	border: 1px solid var(--divider);
+	background-color: var(--bg);
+	overflow: hidden;
+	position: relative;
+	height: auto;
+
+	&.rounded {
+		border-radius: var(--radius);
+	}
+
+	&.noBorder {
+		border: none;
+	}
+}
+
+.routerViewContainer {
+	container-type: inline-size;
+	max-height: var(--embedMaxHeight, none);
+}
+</style>
diff --git a/packages/frontend-embed/src/utils.ts b/packages/frontend-embed/src/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9a2fd0beef7f028a5ccba5cce578a83852a7075b
--- /dev/null
+++ b/packages/frontend-embed/src/utils.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { url } from '@/config.js';
+
+export const acct = (user: Misskey.Acct) => {
+	return Misskey.acct.toString(user);
+};
+
+export const userName = (user: Misskey.entities.User) => {
+	return user.name || user.username;
+};
+
+export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => {
+	return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
+};
+
+export const notePage = note => {
+	return `/notes/${note.id}`;
+};
diff --git a/packages/frontend-embed/src/workers/draw-blurhash.ts b/packages/frontend-embed/src/workers/draw-blurhash.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22de6cd3a8c5aaa227949ce755437ee70441cd6e
--- /dev/null
+++ b/packages/frontend-embed/src/workers/draw-blurhash.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { render } from 'buraha';
+
+const canvas = new OffscreenCanvas(64, 64);
+
+onmessage = (event) => {
+	// console.log(event.data);
+	if (!('id' in event.data && typeof event.data.id === 'string')) {
+		return;
+	}
+	if (!('hash' in event.data && typeof event.data.hash === 'string')) {
+		return;
+	}
+
+	render(event.data.hash, canvas);
+	const bitmap = canvas.transferToImageBitmap();
+	postMessage({ id: event.data.id, bitmap });
+};
diff --git a/packages/frontend-embed/src/workers/test-webgl2.ts b/packages/frontend-embed/src/workers/test-webgl2.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b203ebe666b8ab1718738c6cb5fb11b2e3d5985a
--- /dev/null
+++ b/packages/frontend-embed/src/workers/test-webgl2.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1);
+// 環境によってはOffscreenCanvasが存在しないため
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+const gl = canvas?.getContext('webgl2');
+if (gl) {
+	postMessage({ result: true });
+} else {
+	postMessage({ result: false });
+}
diff --git a/packages/frontend-embed/src/workers/tsconfig.json b/packages/frontend-embed/src/workers/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..8ee893046590f2c9aba8e4cbb8a51a722bdfa6b6
--- /dev/null
+++ b/packages/frontend-embed/src/workers/tsconfig.json
@@ -0,0 +1,5 @@
+{
+	"compilerOptions": {
+		"lib": ["esnext", "webworker"],
+	}
+}
diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..3701343623da5875c54fac3d73f626cc251a6b8f
--- /dev/null
+++ b/packages/frontend-embed/tsconfig.json
@@ -0,0 +1,53 @@
+{
+	"compilerOptions": {
+		"allowJs": true,
+		"noEmitOnError": false,
+		"noImplicitAny": false,
+		"noImplicitReturns": true,
+		"noUnusedParameters": false,
+		"noUnusedLocals": false,
+		"noFallthroughCasesInSwitch": true,
+		"declaration": false,
+		"sourceMap": false,
+		"target": "ES2022",
+		"module": "nodenext",
+		"moduleResolution": "nodenext",
+		"removeComments": false,
+		"noLib": false,
+		"strict": true,
+		"strictNullChecks": true,
+		"experimentalDecorators": true,
+		"resolveJsonModule": true,
+		"allowSyntheticDefaultImports": true,
+		"isolatedModules": true,
+		"useDefineForClassFields": true,
+		"baseUrl": ".",
+		"paths": {
+			"@/*": ["./src/*"],
+			"@@/*": ["../frontend-shared/*"]
+		},
+		"typeRoots": [
+			"./@types",
+			"./node_modules/@types",
+			"./node_modules/@vue-macros",
+			"./node_modules"
+		],
+		"types": [
+			"vite/client",
+		],
+		"lib": [
+			"esnext",
+			"dom",
+			"dom.iterable"
+		],
+		"jsx": "preserve"
+	},
+	"compileOnSave": false,
+	"include": [
+		"./**/*.ts",
+		"./**/*.vue"
+	],
+	"exclude": [
+		".storybook/**/*"
+	]
+}
diff --git a/packages/frontend-embed/vite.config.local-dev.ts b/packages/frontend-embed/vite.config.local-dev.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf2f478887d6e9a8501aaed6e0d0fbad0808c227
--- /dev/null
+++ b/packages/frontend-embed/vite.config.local-dev.ts
@@ -0,0 +1,96 @@
+import dns from 'dns';
+import { readFile } from 'node:fs/promises';
+import type { IncomingMessage } from 'node:http';
+import { defineConfig } from 'vite';
+import type { UserConfig } from 'vite';
+import * as yaml from 'js-yaml';
+import locales from '../../locales/index.js';
+import { getConfig } from './vite.config.js';
+
+dns.setDefaultResultOrder('ipv4first');
+
+const defaultConfig = getConfig();
+
+const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8'));
+
+const httpUrl = `http://localhost:${port}/`;
+const websocketUrl = `ws://localhost:${port}/`;
+
+// activitypubリクエストはProxyを通し、それ以外はViteの開発サーバーを返す
+function varyHandler(req: IncomingMessage) {
+	if (req.headers.accept?.includes('application/activity+json')) {
+		return null;
+	}
+	return '/index.html';
+}
+
+const devConfig: UserConfig = {
+	// 基本の設定は vite.config.js から引き継ぐ
+	...defaultConfig,
+	root: 'src',
+	publicDir: '../assets',
+	base: '/embed',
+	server: {
+		host: 'localhost',
+		port: 5174,
+		proxy: {
+			'/api': {
+				changeOrigin: true,
+				target: httpUrl,
+			},
+			'/assets': httpUrl,
+			'/static-assets': httpUrl,
+			'/client-assets': httpUrl,
+			'/files': httpUrl,
+			'/twemoji': httpUrl,
+			'/fluent-emoji': httpUrl,
+			'/sw.js': httpUrl,
+			'/streaming': {
+				target: websocketUrl,
+				ws: true,
+			},
+			'/favicon.ico': httpUrl,
+			'/robots.txt': httpUrl,
+			'/embed.js': httpUrl,
+			'/identicon': {
+				target: httpUrl,
+				rewrite(path) {
+					return path.replace('@localhost:5173', '');
+				},
+			},
+			'/url': httpUrl,
+			'/proxy': httpUrl,
+			'/_info_card_': httpUrl,
+			'/bios': httpUrl,
+			'/cli': httpUrl,
+			'/inbox': httpUrl,
+			'/emoji/': httpUrl,
+			'/notes': {
+				target: httpUrl,
+				bypass: varyHandler,
+			},
+			'/users': {
+				target: httpUrl,
+				bypass: varyHandler,
+			},
+			'/.well-known': {
+				target: httpUrl,
+			},
+		},
+	},
+	build: {
+		...defaultConfig.build,
+		rollupOptions: {
+			...defaultConfig.build?.rollupOptions,
+			input: 'index.html',
+		},
+	},
+
+	define: {
+		...defaultConfig.define,
+		_LANGS_FULL_: JSON.stringify(Object.entries(locales)),
+	},
+};
+
+export default defineConfig(({ command, mode }) => devConfig);
+
diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..64e67401c2f81bb6fc726787eca639301aa53e0c
--- /dev/null
+++ b/packages/frontend-embed/vite.config.ts
@@ -0,0 +1,156 @@
+import path from 'path';
+import pluginVue from '@vitejs/plugin-vue';
+import { type UserConfig, defineConfig } from 'vite';
+
+import locales from '../../locales/index.js';
+import meta from '../../package.json';
+import packageInfo from './package.json' with { type: 'json' };
+import pluginJson5 from './vite.json5.js';
+
+const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
+
+/**
+ * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。
+ * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK
+ */
+const externalPackages = [
+	// shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む
+	{
+		name: 'shiki',
+		match: /^shiki\/(?<subPkg>(langs|themes))$/,
+		path(id: string, pattern: RegExp): string {
+			const match = pattern.exec(id)?.groups;
+			return match
+				? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}`
+				: id;
+		},
+	},
+];
+
+const hash = (str: string, seed = 0): number => {
+	let h1 = 0xdeadbeef ^ seed,
+		h2 = 0x41c6ce57 ^ seed;
+	for (let i = 0, ch; i < str.length; i++) {
+		ch = str.charCodeAt(i);
+		h1 = Math.imul(h1 ^ ch, 2654435761);
+		h2 = Math.imul(h2 ^ ch, 1597334677);
+	}
+
+	h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+	h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+
+	return 4294967296 * (2097151 & h2) + (h1 >>> 0);
+};
+
+const BASE62_DIGITS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+function toBase62(n: number): string {
+	if (n === 0) {
+		return '0';
+	}
+	let result = '';
+	while (n > 0) {
+		result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result;
+		n = Math.floor(n / BASE62_DIGITS.length);
+	}
+
+	return result;
+}
+
+export function getConfig(): UserConfig {
+	return {
+		base: '/embed_vite/',
+
+		server: {
+			port: 5174,
+		},
+
+		plugins: [
+			pluginVue(),
+			pluginJson5(),
+		],
+
+		resolve: {
+			extensions,
+			alias: {
+				'@/': __dirname + '/src/',
+				'@@/': __dirname + '/../frontend-shared/',
+				'/client-assets/': __dirname + '/assets/',
+				'/static-assets/': __dirname + '/../backend/assets/'
+			},
+		},
+
+		css: {
+			modules: {
+				generateScopedName(name, filename, _css): string {
+					const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, '');
+					if (process.env.NODE_ENV === 'production') {
+						return 'x' + toBase62(hash(id)).substring(0, 4);
+					} else {
+						return id;
+					}
+				},
+			},
+		},
+
+		define: {
+			_VERSION_: JSON.stringify(meta.version),
+			_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+			_ENV_: JSON.stringify(process.env.NODE_ENV),
+			_DEV_: process.env.NODE_ENV !== 'production',
+			_PERF_PREFIX_: JSON.stringify('Misskey:'),
+			__VUE_OPTIONS_API__: false,
+			__VUE_PROD_DEVTOOLS__: false,
+		},
+
+		build: {
+			target: [
+				'chrome116',
+				'firefox116',
+				'safari16',
+			],
+			manifest: 'manifest.json',
+			rollupOptions: {
+				input: {
+					app: './src/boot.ts',
+				},
+				external: externalPackages.map(p => p.match),
+				output: {
+					manualChunks: {
+						vue: ['vue'],
+					},
+					chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
+					assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
+					paths(id) {
+						for (const p of externalPackages) {
+							if (p.match.test(id)) {
+								return p.path(id, p.match);
+							}
+						}
+
+						return id;
+					},
+				},
+			},
+			cssCodeSplit: true,
+			outDir: __dirname + '/../../built/_frontend_embed_vite_',
+			assetsDir: '.',
+			emptyOutDir: false,
+			sourcemap: process.env.NODE_ENV === 'development',
+			reportCompressedSize: false,
+
+			// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
+			commonjsOptions: {
+				include: [/misskey-js/, /node_modules/],
+			},
+		},
+
+		worker: {
+			format: 'es',
+		},
+	};
+}
+
+const config = defineConfig(({ command, mode }) => getConfig());
+
+export default config;
diff --git a/packages/frontend-embed/vite.json5.ts b/packages/frontend-embed/vite.json5.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87b67c2142414a94763808ebd83e06cdab3d588c
--- /dev/null
+++ b/packages/frontend-embed/vite.json5.ts
@@ -0,0 +1,48 @@
+// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json
+
+import JSON5 from 'json5';
+import { Plugin } from 'rollup';
+import { createFilter, dataToEsm } from '@rollup/pluginutils';
+import { RollupJsonOptions } from '@rollup/plugin-json';
+
+// json5 extends SyntaxError with additional fields (without subclassing)
+// https://github.com/json5/json5/blob/de344f0619bda1465a6e25c76f1c0c3dda8108d9/lib/parse.js#L1111-L1112
+interface Json5SyntaxError extends SyntaxError {
+	lineNumber: number;
+	columnNumber: number;
+}
+
+export default function json5(options: RollupJsonOptions = {}): Plugin {
+	const filter = createFilter(options.include, options.exclude);
+	const indent = 'indent' in options ? options.indent : '\t';
+
+	return {
+		name: 'json5',
+
+		// eslint-disable-next-line no-shadow
+		transform(json, id) {
+			if (id.slice(-6) !== '.json5' || !filter(id)) return null;
+
+			try {
+				const parsed = JSON5.parse(json);
+				return {
+					code: dataToEsm(parsed, {
+						preferConst: options.preferConst,
+						compact: options.compact,
+						namedExports: options.namedExports,
+						indent,
+					}),
+					map: { mappings: '' },
+				};
+			} catch (err) {
+				if (!(err instanceof SyntaxError)) {
+					throw err;
+				}
+				const message = 'Could not parse JSON5 file';
+				const { lineNumber, columnNumber } = err as Json5SyntaxError;
+				this.warn({ message, id, loc: { line: lineNumber, column: columnNumber } });
+				return null;
+			}
+		},
+	};
+}
diff --git a/packages/frontend-embed/vue-shims.d.ts b/packages/frontend-embed/vue-shims.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eba994772dd4680f58296de2c82d714a8f044ba3
--- /dev/null
+++ b/packages/frontend-embed/vue-shims.d.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+declare module "*.vue" {
+	import { defineComponent } from "vue";
+	const component: ReturnType<typeof defineComponent>;
+	export default component;
+}
diff --git a/packages/frontend-shared/.gitignore b/packages/frontend-shared/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..5f6be09d7c01e1fa28715890bf8a17338cc73bc7
--- /dev/null
+++ b/packages/frontend-shared/.gitignore
@@ -0,0 +1,2 @@
+/storybook-static
+js-built
diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js
new file mode 100644
index 0000000000000000000000000000000000000000..17b6da8d30a2ab847aef0181ffcf82db83057f27
--- /dev/null
+++ b/packages/frontend-shared/build.js
@@ -0,0 +1,106 @@
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+import * as esbuild from 'esbuild';
+import { build } from 'esbuild';
+import { globSync } from 'glob';
+import { execa } from 'execa';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
+
+const entryPoints = globSync('./js/**/**.{ts,tsx}');
+
+/** @type {import('esbuild').BuildOptions} */
+const options = {
+	entryPoints,
+	minify: process.env.NODE_ENV === 'production',
+	outdir: './js-built',
+	target: 'es2022',
+	platform: 'browser',
+	format: 'esm',
+	sourcemap: 'linked',
+};
+
+// js-built配下をすべて削除する
+fs.rmSync('./js-built', { recursive: true, force: true });
+
+if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) {
+	await watchSrc();
+} else {
+	await buildSrc();
+}
+
+async function buildSrc() {
+	console.log(`[${_package.name}] start building...`);
+
+	await build(options)
+		.then(() => {
+			console.log(`[${_package.name}] build succeeded.`);
+		})
+		.catch((err) => {
+			process.stderr.write(err.stderr);
+			process.exit(1);
+		});
+
+	if (process.env.NODE_ENV === 'production') {
+		console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`);
+	} else {
+		await buildDts();
+	}
+
+	fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json');
+
+	console.log(`[${_package.name}] finish building.`);
+}
+
+function buildDts() {
+	return execa(
+		'tsc',
+		[
+			'--project', 'tsconfig.json',
+			'--outDir', 'js-built',
+			'--declaration', 'true',
+			'--emitDeclarationOnly', 'true',
+		],
+		{
+			stdout: process.stdout,
+			stderr: process.stderr,
+		},
+	);
+}
+
+async function watchSrc() {
+	const plugins = [{
+		name: 'gen-dts',
+		setup(build) {
+			build.onStart(() => {
+				console.log(`[${_package.name}] detect changed...`);
+			});
+			build.onEnd(async result => {
+				if (result.errors.length > 0) {
+					console.error(`[${_package.name}] watch build failed:`, result);
+					return;
+				}
+				await buildDts();
+			});
+		},
+	}];
+
+	console.log(`[${_package.name}] start watching...`);
+
+	const context = await esbuild.context({ ...options, plugins });
+	await context.watch();
+
+	await new Promise((resolve, reject) => {
+		process.on('SIGHUP', resolve);
+		process.on('SIGINT', resolve);
+		process.on('SIGTERM', resolve);
+		process.on('uncaughtException', reject);
+		process.on('exit', resolve);
+	}).finally(async () => {
+		await context.dispose();
+		console.log(`[${_package.name}] finish watching.`);
+	});
+}
diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..a15fb29e37112d6c15e5d972fcf0e56a9ec06280
--- /dev/null
+++ b/packages/frontend-shared/eslint.config.js
@@ -0,0 +1,96 @@
+import globals from 'globals';
+import tsParser from '@typescript-eslint/parser';
+import parser from 'vue-eslint-parser';
+import pluginVue from 'eslint-plugin-vue';
+import pluginMisskey from '@misskey-dev/eslint-plugin';
+import sharedConfig from '../shared/eslint.config.js';
+
+// eslint-disable-next-line import/no-default-export
+export default [
+	...sharedConfig,
+	{
+		files: ['**/*.vue'],
+		...pluginMisskey.configs.typescript,
+	},
+	...pluginVue.configs['flat/recommended'],
+	{
+		files: ['js/**/*.{ts,vue}', '**/*.vue'],
+		languageOptions: {
+			globals: {
+				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
+				...globals.browser,
+
+				// Node.js
+				module: false,
+				require: false,
+				__dirname: false,
+
+				// Misskey
+				_DEV_: false,
+				_LANGS_: false,
+				_VERSION_: false,
+				_ENV_: false,
+				_PERF_PREFIX_: false,
+				_DATA_TRANSFER_DRIVE_FILE_: false,
+				_DATA_TRANSFER_DRIVE_FOLDER_: false,
+				_DATA_TRANSFER_DECK_COLUMN_: false,
+			},
+			parser,
+			parserOptions: {
+				extraFileExtensions: ['.vue'],
+				parser: tsParser,
+				project: ['./tsconfig.json'],
+				sourceType: 'module',
+				tsconfigRootDir: import.meta.dirname,
+			},
+		},
+		rules: {
+			'@typescript-eslint/no-empty-interface': ['error', {
+				allowSingleExtends: true,
+			}],
+			// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
+			// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
+			'id-denylist': ['error', 'window', 'e'],
+			'no-shadow': ['warn'],
+			'vue/attributes-order': ['error', {
+				alphabetical: false,
+			}],
+			'vue/no-use-v-if-with-v-for': ['error', {
+				allowUsingIterationVar: false,
+			}],
+			'vue/no-ref-as-operand': 'error',
+			'vue/no-multi-spaces': ['error', {
+				ignoreProperties: false,
+			}],
+			'vue/no-v-html': 'warn',
+			'vue/order-in-components': 'error',
+			'vue/html-indent': ['warn', 'tab', {
+				attribute: 1,
+				baseIndent: 0,
+				closeBracket: 0,
+				alignAttributesVertically: true,
+				ignores: [],
+			}],
+			'vue/html-closing-bracket-spacing': ['warn', {
+				startTag: 'never',
+				endTag: 'never',
+				selfClosingTag: 'never',
+			}],
+			'vue/multi-word-component-names': 'warn',
+			'vue/require-v-for-key': 'warn',
+			'vue/no-unused-components': 'warn',
+			'vue/no-unused-vars': 'warn',
+			'vue/no-dupe-keys': 'warn',
+			'vue/valid-v-for': 'warn',
+			'vue/return-in-computed-property': 'warn',
+			'vue/no-setup-props-reactivity-loss': 'warn',
+			'vue/max-attributes-per-line': 'off',
+			'vue/html-self-closing': 'off',
+			'vue/singleline-html-element-content-newline': 'off',
+			'vue/v-on-event-hyphenation': ['error', 'never', {
+				autofix: true,
+			}],
+			'vue/attribute-hyphenation': ['error', 'never'],
+		},
+	},
+];
diff --git a/packages/frontend/src/const.ts b/packages/frontend-shared/js/const.ts
similarity index 98%
rename from packages/frontend/src/const.ts
rename to packages/frontend-shared/js/const.ts
index e135bc69a0f3d6497bd450fdb0fadbb279a857c1..8391fb638c5e2e582b12bb49bfe3b0b3fa320340 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -127,7 +127,7 @@ export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 	position: ['x=', 'y='],
 	fg: ['color='],
 	bg: ['color='],
-  border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
+	border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
 	font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
 	blur: [],
 	rainbow: ['speed=', 'delay='],
diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d5555a98c3b0fae39ba44479cdcd5fa999d87e00
--- /dev/null
+++ b/packages/frontend-shared/js/embed-page.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+//#region Embed関連の定義
+
+/** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */
+const embeddableEntities = [
+	'notes',
+	'user-timeline',
+	'clips',
+	'tags',
+] as const;
+
+/** 埋め込みの対象となるエンティティ */
+export type EmbeddableEntity = typeof embeddableEntities[number];
+
+/** 内部でスクロールがあるページ */
+export const embedRouteWithScrollbar: EmbeddableEntity[] = [
+	'clips',
+	'tags',
+	'user-timeline',
+];
+
+/** 埋め込みコードのパラメータ */
+export type EmbedParams = {
+	maxHeight?: number;
+	colorMode?: 'light' | 'dark';
+	rounded?: boolean;
+	border?: boolean;
+	autoload?: boolean;
+	header?: boolean;
+};
+
+/** 正規化されたパラメータ */
+export type ParsedEmbedParams = Required<Omit<EmbedParams, 'maxHeight' | 'colorMode'>> & Pick<EmbedParams, 'maxHeight' | 'colorMode'>;
+
+/** パラメータのデフォルトの値 */
+export const defaultEmbedParams = {
+	maxHeight: undefined,
+	colorMode: undefined,
+	rounded: true,
+	border: true,
+	autoload: false,
+	header: true,
+} as const satisfies EmbedParams;
+
+//#endregion
+
+/**
+ * パラメータを正規化する(埋め込みページ初期化用)
+ * @param searchParams URLSearchParamsもしくはクエリ文字列
+ * @returns 正規化されたパラメータ
+ */
+export function parseEmbedParams(searchParams: URLSearchParams | string): ParsedEmbedParams {
+	let _searchParams: URLSearchParams;
+	if (typeof searchParams === 'string') {
+		_searchParams = new URLSearchParams(searchParams);
+	} else if (searchParams instanceof URLSearchParams) {
+		_searchParams = searchParams;
+	} else {
+		throw new Error('searchParams must be URLSearchParams or string');
+	}
+
+	function convertBoolean(value: string | null): boolean | undefined {
+		if (value === 'true') {
+			return true;
+		} else if (value === 'false') {
+			return false;
+		}
+		return undefined;
+	}
+
+	function convertNumber(value: string | null): number | undefined {
+		if (value != null && !isNaN(Number(value))) {
+			return Number(value);
+		}
+		return undefined;
+	}
+
+	function convertColorMode(value: string | null): 'light' | 'dark' | undefined {
+		if (value != null && ['light', 'dark'].includes(value)) {
+			return value as 'light' | 'dark';
+		}
+		return undefined;
+	}
+
+	return {
+		maxHeight: convertNumber(_searchParams.get('maxHeight')) ?? defaultEmbedParams.maxHeight,
+		colorMode: convertColorMode(_searchParams.get('colorMode')) ?? defaultEmbedParams.colorMode,
+		rounded: convertBoolean(_searchParams.get('rounded')) ?? defaultEmbedParams.rounded,
+		border: convertBoolean(_searchParams.get('border')) ?? defaultEmbedParams.border,
+		autoload: convertBoolean(_searchParams.get('autoload')) ?? defaultEmbedParams.autoload,
+		header: convertBoolean(_searchParams.get('header')) ?? defaultEmbedParams.header,
+	};
+}
diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend-shared/js/emoji-base.ts
similarity index 100%
rename from packages/frontend/src/scripts/emoji-base.ts
rename to packages/frontend-shared/js/emoji-base.ts
diff --git a/packages/frontend/src/emojilist.json b/packages/frontend-shared/js/emojilist.json
similarity index 100%
rename from packages/frontend/src/emojilist.json
rename to packages/frontend-shared/js/emojilist.json
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend-shared/js/emojilist.ts
similarity index 96%
rename from packages/frontend/src/scripts/emojilist.ts
rename to packages/frontend-shared/js/emojilist.ts
index 6565feba97d2d1e1b3831cd5fabc882b9d49d149..bde30a864fd0259531399c4e0f8ca7ae6a4acfd0 100644
--- a/packages/frontend/src/scripts/emojilist.ts
+++ b/packages/frontend-shared/js/emojilist.ts
@@ -12,12 +12,12 @@ export type UnicodeEmojiDef = {
 }
 
 // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
-import _emojilist from '../emojilist.json';
+import _emojilist from './emojilist.json';
 
 export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
 	name: x[1] as string,
 	char: x[0] as string,
-	category: unicodeEmojiCategories[x[2]],
+	category: unicodeEmojiCategories[x[2] as number],
 }));
 
 const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>(
diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend-shared/js/extract-avg-color-from-blurhash.ts
similarity index 100%
rename from packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts
rename to packages/frontend-shared/js/extract-avg-color-from-blurhash.ts
diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend-shared/js/i18n.ts
similarity index 91%
rename from packages/frontend/src/scripts/i18n.ts
rename to packages/frontend-shared/js/i18n.ts
index b258a2a6781f770e7023e55eac2056005e7a603a..18232691fa7e6069df874782e93e8070a795cc97 100644
--- a/packages/frontend/src/scripts/i18n.ts
+++ b/packages/frontend-shared/js/i18n.ts
@@ -2,7 +2,10 @@
  * SPDX-FileCopyrightText: syuilo and misskey-project
  * SPDX-License-Identifier: AGPL-3.0-only
  */
-import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
+import type { ILocale, ParameterizedString } from '../../../locales/index.js';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type TODO = any;
 
 type FlattenKeys<T extends ILocale, TPrediction> = keyof {
 	[K in keyof T as T[K] extends ILocale
@@ -32,15 +35,18 @@ type Tsx<T extends ILocale> = {
 
 export class I18n<T extends ILocale> {
 	private tsxCache?: Tsx<T>;
+	private devMode: boolean;
+
+	constructor(public locale: T, devMode = false) {
+		this.devMode = devMode;
 
-	constructor(public locale: T) {
 		//#region BIND
 		this.t = this.t.bind(this);
 		//#endregion
 	}
 
 	public get ts(): T {
-		if (_DEV_) {
+		if (this.devMode) {
 			class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
 				get(target: TTarget, p: string | symbol): unknown {
 					const value = target[p as keyof TTarget];
@@ -72,7 +78,7 @@ export class I18n<T extends ILocale> {
 	}
 
 	public get tsx(): Tsx<T> {
-		if (_DEV_) {
+		if (this.devMode) {
 			if (this.tsxCache) {
 				return this.tsxCache;
 			}
@@ -113,7 +119,7 @@ export class I18n<T extends ILocale> {
 							return () => value;
 						}
 
-						return (arg) => {
+						return (arg: TODO) => {
 							let str = quasis[0];
 
 							for (let i = 0; i < expressions.length; i++) {
@@ -152,7 +158,7 @@ export class I18n<T extends ILocale> {
 				const value = target[k as keyof typeof target];
 
 				if (typeof value === 'object') {
-					result[k] = build(value as ILocale);
+					(result as TODO)[k] = build(value as ILocale);
 				} else if (typeof value === 'string') {
 					const quasis: string[] = [];
 					const expressions: string[] = [];
@@ -179,7 +185,7 @@ export class I18n<T extends ILocale> {
 						continue;
 					}
 
-					result[k] = (arg) => {
+					(result as TODO)[k] = (arg: TODO) => {
 						let str = quasis[0];
 
 						for (let i = 0; i < expressions.length; i++) {
@@ -208,9 +214,9 @@ export class I18n<T extends ILocale> {
 		let str: string | ParameterizedString | ILocale = this.locale;
 
 		for (const k of key.split('.')) {
-			str = str[k];
+			str = (str as TODO)[k];
 
-			if (_DEV_) {
+			if (this.devMode) {
 				if (typeof str === 'undefined') {
 					console.error(`Unexpected locale key: ${key}`);
 					return key;
@@ -219,7 +225,7 @@ export class I18n<T extends ILocale> {
 		}
 
 		if (args) {
-			if (_DEV_) {
+			if (this.devMode) {
 				const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
 
 				if (missing.length) {
@@ -230,7 +236,7 @@ export class I18n<T extends ILocale> {
 			for (const [k, v] of Object.entries(args)) {
 				const search = `{${k}}`;
 
-				if (_DEV_) {
+				if (this.devMode) {
 					if (!(str as string).includes(search)) {
 						console.error(`Unexpected locale parameter: ${k} at ${key}`);
 					}
diff --git a/packages/frontend-shared/js/media-proxy.ts b/packages/frontend-shared/js/media-proxy.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2837870c9a639d7cda6934c1ea805f87e6837bda
--- /dev/null
+++ b/packages/frontend-shared/js/media-proxy.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { query } from './url.js';
+
+export class MediaProxy {
+	private serverMetadata: Misskey.entities.MetaDetailed;
+	private url: string;
+
+	constructor(serverMetadata: Misskey.entities.MetaDetailed, url: string) {
+		this.serverMetadata = serverMetadata;
+		this.url = url;
+	}
+
+	public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
+		const localProxy = `${this.url}/proxy`;
+		let _imageUrl = imageUrl;
+
+		if (imageUrl.startsWith(this.serverMetadata.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
+			// もう既にproxyっぽそうだったらurlを取り出す
+			_imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
+		}
+
+		return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${
+			type === 'preview' ? 'preview.webp'
+			: 'image.webp'
+		}?${query({
+			url: _imageUrl,
+			...(!noFallback ? { 'fallback': '1' } : {}),
+			...(type ? { [type]: '1' } : {}),
+			...(mustOrigin ? { origin: '1' } : {}),
+		})}`;
+	}
+
+	public getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
+		if (imageUrl == null) return null;
+		return this.getProxiedImageUrl(imageUrl, type);
+	}
+
+	public getStaticImageUrl(baseUrl: string): string {
+		const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, this.url);
+
+		if (u.href.startsWith(`${this.url}/emoji/`)) {
+			// もう既にemojiっぽそうだったらsearchParams付けるだけ
+			u.searchParams.set('static', '1');
+			return u.href;
+		}
+
+		if (u.href.startsWith(this.serverMetadata.mediaProxy + '/')) {
+			// もう既にproxyっぽそうだったらsearchParams付けるだけ
+			u.searchParams.set('static', '1');
+			return u.href;
+		}
+
+		return `${this.serverMetadata.mediaProxy}/static.webp?${query({
+			url: u.href,
+			static: '1',
+		})}`;
+	}
+}
diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend-shared/js/scroll.ts
similarity index 98%
rename from packages/frontend/src/scripts/scroll.ts
rename to packages/frontend-shared/js/scroll.ts
index f0274034b5b3d027824c9041236386d240b577f3..1062e5252febafb4955e1211c87fc5e941f3a76a 100644
--- a/packages/frontend/src/scripts/scroll.ts
+++ b/packages/frontend-shared/js/scroll.ts
@@ -45,7 +45,7 @@ export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, o
 
 	const container = getScrollContainer(el) ?? window;
 
-	const onScroll = ev => {
+	const onScroll = () => {
 		if (!document.body.contains(el)) return;
 		if (isTopVisible(el, tolerance)) {
 			cb();
@@ -69,7 +69,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
 	}
 
 	const containerOrWindow = container ?? window;
-	const onScroll = ev => {
+	const onScroll = () => {
 		if (!document.body.contains(el)) return;
 		if (isBottomVisible(el, 1, container)) {
 			cb();
diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend-shared/js/url.ts
similarity index 70%
rename from packages/frontend/src/scripts/url.ts
rename to packages/frontend-shared/js/url.ts
index 5a8265af9e1050d0c7e6ac260828d26f92b2f499..eb830b1eeaec7ac22ab64d01b2fb8ba5058ae246 100644
--- a/packages/frontend/src/scripts/url.ts
+++ b/packages/frontend-shared/js/url.ts
@@ -8,18 +8,18 @@
  * 2. プロパティがundefinedの時はクエリを付けない
  * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない)
  */
-export function query(obj: Record<string, any>): string {
+export function query(obj: Record<string, string | number | boolean>): string {
 	const params = Object.entries(obj)
-		.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
-		.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
+		.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) // eslint-disable-line @typescript-eslint/no-unnecessary-condition
+		.reduce<Record<string, string | number | boolean>>((a, [k, v]) => (a[k] = v, a), {});
 
 	return Object.entries(params)
 		.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
 		.join('&');
 }
 
-export function appendQuery(url: string, query: string): string {
-	return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
+export function appendQuery(url: string, queryString: string): string {
+	return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${queryString}`;
 }
 
 export function extractDomain(url: string) {
diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend-shared/js/use-document-visibility.ts
similarity index 85%
rename from packages/frontend/src/scripts/use-document-visibility.ts
rename to packages/frontend-shared/js/use-document-visibility.ts
index a8f4d5e03ae2158d609ca78dcc1a8f85f6a4064b..b1197e68dae2bcdedeefb09de5cf7b3202d289b3 100644
--- a/packages/frontend/src/scripts/use-document-visibility.ts
+++ b/packages/frontend-shared/js/use-document-visibility.ts
@@ -3,7 +3,8 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { onMounted, onUnmounted, ref, Ref } from 'vue';
+import { onMounted, onUnmounted, ref } from 'vue';
+import type { Ref } from 'vue';
 
 export function useDocumentVisibility(): Ref<DocumentVisibilityState> {
 	const visibility = ref(document.visibilityState);
diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend-shared/js/use-interval.ts
similarity index 100%
rename from packages/frontend/src/scripts/use-interval.ts
rename to packages/frontend-shared/js/use-interval.ts
diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..9981d10dd25d82409262c9da1b3e1db4fdfe27e0
--- /dev/null
+++ b/packages/frontend-shared/package.json
@@ -0,0 +1,39 @@
+{
+	"name": "frontend-shared",
+	"type": "module",
+	"main": "./js-built/index.js",
+	"types": "./js-built/index.d.ts",
+	"exports": {
+		".": {
+			"import": "./js-built/index.js",
+			"types": "./js-built/index.d.ts"
+		},
+		"./*": {
+			"import": "./js-built/*",
+			"types": "./js-built/*"
+		}
+	},
+	"scripts": {
+		"build": "node ./build.js",
+		"watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"",
+		"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
+		"typecheck": "tsc --noEmit",
+		"lint": "pnpm typecheck && pnpm eslint"
+	},
+	"devDependencies": {
+		"@types/node": "20.14.12",
+		"@typescript-eslint/eslint-plugin": "7.17.0",
+		"@typescript-eslint/parser": "7.17.0",
+		"esbuild": "0.23.0",
+		"eslint-plugin-vue": "9.27.0",
+		"typescript": "5.5.4",
+		"vue-eslint-parser": "9.4.3"
+	},
+	"files": [
+		"js-built"
+	],
+	"dependencies": {
+		"misskey-js": "workspace:*",
+		"vue": "3.4.37"
+	}
+}
diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5
similarity index 100%
rename from packages/frontend/src/themes/_dark.json5
rename to packages/frontend-shared/themes/_dark.json5
diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5
similarity index 100%
rename from packages/frontend/src/themes/_light.json5
rename to packages/frontend-shared/themes/_light.json5
diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5
similarity index 100%
rename from packages/frontend/src/themes/d-astro.json5
rename to packages/frontend-shared/themes/d-astro.json5
diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5
similarity index 100%
rename from packages/frontend/src/themes/d-botanical.json5
rename to packages/frontend-shared/themes/d-botanical.json5
diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend-shared/themes/d-cherry.json5
similarity index 100%
rename from packages/frontend/src/themes/d-cherry.json5
rename to packages/frontend-shared/themes/d-cherry.json5
diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5
similarity index 100%
rename from packages/frontend/src/themes/d-dark.json5
rename to packages/frontend-shared/themes/d-dark.json5
diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5
similarity index 100%
rename from packages/frontend/src/themes/d-future.json5
rename to packages/frontend-shared/themes/d-future.json5
diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5
similarity index 100%
rename from packages/frontend/src/themes/d-green-lime.json5
rename to packages/frontend-shared/themes/d-green-lime.json5
diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5
similarity index 100%
rename from packages/frontend/src/themes/d-green-orange.json5
rename to packages/frontend-shared/themes/d-green-orange.json5
diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend-shared/themes/d-ice.json5
similarity index 100%
rename from packages/frontend/src/themes/d-ice.json5
rename to packages/frontend-shared/themes/d-ice.json5
diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5
similarity index 100%
rename from packages/frontend/src/themes/d-persimmon.json5
rename to packages/frontend-shared/themes/d-persimmon.json5
diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5
similarity index 100%
rename from packages/frontend/src/themes/d-u0.json5
rename to packages/frontend-shared/themes/d-u0.json5
diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend-shared/themes/l-apricot.json5
similarity index 100%
rename from packages/frontend/src/themes/l-apricot.json5
rename to packages/frontend-shared/themes/l-apricot.json5
diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5
similarity index 100%
rename from packages/frontend/src/themes/l-botanical.json5
rename to packages/frontend-shared/themes/l-botanical.json5
diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend-shared/themes/l-cherry.json5
similarity index 100%
rename from packages/frontend/src/themes/l-cherry.json5
rename to packages/frontend-shared/themes/l-cherry.json5
diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5
similarity index 100%
rename from packages/frontend/src/themes/l-coffee.json5
rename to packages/frontend-shared/themes/l-coffee.json5
diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5
similarity index 100%
rename from packages/frontend/src/themes/l-light.json5
rename to packages/frontend-shared/themes/l-light.json5
diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5
similarity index 100%
rename from packages/frontend/src/themes/l-rainy.json5
rename to packages/frontend-shared/themes/l-rainy.json5
diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend-shared/themes/l-sushi.json5
similarity index 100%
rename from packages/frontend/src/themes/l-sushi.json5
rename to packages/frontend-shared/themes/l-sushi.json5
diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5
similarity index 100%
rename from packages/frontend/src/themes/l-u0.json5
rename to packages/frontend-shared/themes/l-u0.json5
diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5
similarity index 100%
rename from packages/frontend/src/themes/l-vivid.json5
rename to packages/frontend-shared/themes/l-vivid.json5
diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..fa0b765534bbe9618ae57ca26e0ba87958bf3736
--- /dev/null
+++ b/packages/frontend-shared/tsconfig.json
@@ -0,0 +1,34 @@
+{
+	"$schema": "https://json.schemastore.org/tsconfig",
+	"compilerOptions": {
+		"target": "ES2022",
+		"module": "nodenext",
+		"moduleResolution": "nodenext",
+		"declaration": true,
+		"declarationMap": true,
+		"sourceMap": false,
+		"outDir": "./js-built/",
+		"removeComments": true,
+		"resolveJsonModule": true,
+		"strict": true,
+		"strictFunctionTypes": true,
+		"strictNullChecks": true,
+		"experimentalDecorators": true,
+		"noImplicitReturns": true,
+		"esModuleInterop": true,
+		"typeRoots": [
+			"./node_modules/@types"
+		],
+		"lib": [
+			"esnext",
+			"dom"
+		]
+	},
+	"include": [
+		"js/**/*"
+	],
+	"exclude": [
+		"node_modules",
+		"test/**/*"
+	]
+}
diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts
index fb93d7be1353206cf23f12950ecfd8659745db6c..e5573f2ac3a0017f8d9c7a3e3a4f704831ca15e1 100644
--- a/packages/frontend/.storybook/preload-theme.ts
+++ b/packages/frontend/.storybook/preload-theme.ts
@@ -30,7 +30,7 @@ const keys = [
 	'd-u0',
 ]
 
-await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
+await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
 	writeFile(
 		new URL('./themes.ts', import.meta.url),
 		`export default ${JSON.stringify(
diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts
index 0a7281898d9896a1fc82f38c2d61bcb23102c0f2..70afc356c19d9d281a640f686f1974dc1b541805 100644
--- a/packages/frontend/@types/theme.d.ts
+++ b/packages/frontend/@types/theme.d.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-declare module '@/themes/*.json5' {
+declare module '@@/themes/*.json5' {
 	import { Theme } from '@/scripts/theme.js';
 
 	const theme: Theme;
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 1464be18a7faa484bdde0ea6884632f4c044af66..67be7f0598a0adaff7f728f00409d25fd0ce8bcb 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -55,6 +55,7 @@
 		"misskey-bubble-game": "workspace:*",
 		"misskey-js": "workspace:*",
 		"misskey-reversi": "workspace:*",
+		"frontend-shared": "workspace:*",
 		"photoswipe": "5.4.4",
 		"punycode": "2.3.1",
 		"rollup": "4.19.1",
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index d86ae18ffeaccf7297b0d47bb5ea507db8d2cdbd..19d30f64cebdddac69b37506fc7a2b2385c7e68a 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js';
 import { deckStore } from '@/ui/deck/deck-store.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { fetchCustomEmojis } from '@/custom-emojis.js';
-import { setupRouter } from '@/router/definition.js';
+import { setupRouter } from '@/router/main.js';
+import { createMainRouter } from '@/router/definition.js';
 
 export async function common(createVue: () => App<Element>) {
 	console.info(`Misskey v${version}`);
@@ -239,7 +240,7 @@ export async function common(createVue: () => App<Element>) {
 
 	const app = createVue();
 
-	setupRouter(app);
+	setupRouter(app, createMainRouter);
 
 	if (_DEV_) {
 		app.config.performance = true;
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 3e7c4f26f807ca078098bb29b93c5708a416c362..b31281dcf286d3c6dbab6dff7f571365d0705aba 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -22,6 +22,7 @@ import { deckStore } from '@/ui/deck/deck-store.js';
 import { emojiPicker } from '@/scripts/emoji-picker.js';
 import { mainRouter } from '@/router/main.js';
 import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
+import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
 
 export async function mainBoot() {
 	const { isClientUpdated } = await common(() => createApp(
@@ -62,6 +63,18 @@ export async function mainBoot() {
 		}
 	});
 
+	stream.on('emojiAdded', emojiData => {
+		addCustomEmoji(emojiData.emoji);
+	});
+
+	stream.on('emojiUpdated', emojiData => {
+		updateCustomEmojis(emojiData.emojis);
+	});
+
+	stream.on('emojiDeleted', emojiData => {
+		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
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 932c4ecb2e9c1d2326c07cfc9b16231f83ea813a..f547991369377301b29c115f62775168d33f0778 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts">
 import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 import sanitizeHtml from 'sanitize-html';
+import { emojilist, getEmojiName } from '@@/js/emojilist.js';
 import contains from '@/scripts/contains.js';
-import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
+import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.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 { emojilist, getEmojiName } from '@/scripts/emojilist.js';
 import { i18n } from '@/i18n.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
-import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
+import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
 import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
 
 const lib = emojilist.filter(x => x.category !== 'flags');
diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue
index 00506fb73513d6e3dba787706f6d409e94da9ad4..9a0a9fba0515297755b5d60f4687a825f9c11290 100644
--- a/packages/frontend/src/components/MkClickerGame.vue
+++ b/packages/frontend/src/components/MkClickerGame.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onMounted, onUnmounted, ref } from 'vue';
 import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
 import * as os from '@/os.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import * as game from '@/scripts/clicker-game.js';
 import number from '@/filters/number.js';
 import { claimAchievement } from '@/scripts/achievements.js';
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index 1d4c0b6366c802671a1d1ca157176c5d95fcd3d8..716dd92678726f380f73e82a0781851547b30449 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div :class="$style.codeBlockRoot">
-	<button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
+	<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
 		<i class="ti ti-copy"></i>
 	</button>
 	<Suspense>
@@ -32,12 +32,17 @@ import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
 	code: string;
+	forceShow?: boolean;
+	copyButton?: boolean;
 	lang?: string;
-}>();
+}>(), {
+	copyButton: true,
+	forceShow: false,
+});
 
-const show = ref(!defaultStore.state.dataSaver.code);
+const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code);
 
 const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
 
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..51630c427c2604357fc6b1bdaaa37a884cb6f32a
--- /dev/null
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -0,0 +1,412 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+	ref="dialogEl"
+	:width="1000"
+	:height="600"
+	:scroll="false"
+	:withOkButton="false"
+	@close="cancel()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ i18n.ts._embedCodeGen.title }}</template>
+
+	<div :class="$style.embedCodeGenRoot">
+		<Transition
+			mode="out-in"
+			:enterActiveClass="$style.transition_x_enterActive"
+			:leaveActiveClass="$style.transition_x_leaveActive"
+			:enterFromClass="$style.transition_x_enterFrom"
+			:leaveToClass="$style.transition_x_leaveTo"
+		>
+			<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
+				<div
+					:class="$style.embedCodeGenPreviewRoot"
+				>
+					<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
+					<div :class="$style.embedCodeGenPreviewWrapper">
+						<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
+						<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
+							<div
+								:class="$style.embedCodeGenPreviewResizer"
+								:style="{ transform: iframeStyle }"
+							>
+								<iframe
+									ref="iframeEl"
+									:src="embedPreviewUrl"
+									:class="$style.embedCodeGenPreviewIframe"
+									:style="{ height: `${iframeHeight}px` }"
+									@load="iframeOnLoad"
+								></iframe>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div :class="$style.embedCodeGenSettings" class="_gaps">
+					<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
+						<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
+						<template #suffix>px</template>
+						<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
+					</MkInput>
+					<MkSelect v-model="colorMode">
+						<template #label>{{ i18n.ts.theme }}</template>
+						<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
+						<option value="light">{{ i18n.ts.light }}</option>
+						<option value="dark">{{ i18n.ts.dark }}</option>
+					</MkSelect>
+					<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
+					<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
+					<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
+					<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
+					<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
+					<div class="_buttons">
+						<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
+						<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
+					</div>
+				</div>
+			</div>
+			<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
+				<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
+					<div class="_gaps_s">
+						<div :class="$style.embedCodeGenResultHeadingIcon"><i class="ti ti-check"></i></div>
+						<div :class="$style.embedCodeGenResultHeading">{{ i18n.ts._embedCodeGen.codeGenerated }}</div>
+						<div :class="$style.embedCodeGenResultDescription">{{ i18n.ts._embedCodeGen.codeGeneratedDescription }}</div>
+					</div>
+					<div class="_gaps_s">
+						<MkCode :code="result" lang="html" :forceShow="true" :copyButton="false" :class="$style.embedCodeGenResultCode"/>
+						<MkButton :class="$style.embedCodeGenResultButtons" rounded primary @click="doCopy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+					</div>
+					<MkButton :class="$style.embedCodeGenResultButtons" rounded transparent @click="close">{{ i18n.ts.close }}</MkButton>
+				</div>
+			</div>
+		</Transition>
+	</div>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
+import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+
+import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkButton from '@/components/MkButton.vue';
+
+import MkCode from '@/components/MkCode.vue';
+import MkInfo from '@/components/MkInfo.vue';
+
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { url } from '@/config.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
+import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
+
+const emit = defineEmits<{
+	(ev: 'ok'): void;
+	(ev: 'cancel'): void;
+	(ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+	entity: EmbeddableEntity;
+	id: string;
+	params?: EmbedParams;
+}>();
+
+//#region Modalの制御
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function cancel() {
+	emit('cancel');
+	dialogEl.value?.close();
+}
+
+function close() {
+	dialogEl.value?.close();
+}
+
+const phase = ref<'input' | 'result'>('input');
+//#endregion
+
+//#region 埋め込みURL生成・カスタマイズ
+
+// 本URL生成用params
+const paramsForUrl = computed<EmbedParams>(() => ({
+	header: header.value,
+	maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
+	colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
+	rounded: rounded.value,
+	border: border.value,
+}));
+
+// プレビュー用params(手動で更新を掛けるのでref)
+const paramsForPreview = ref<EmbedParams>(props.params ?? {});
+
+const embedPreviewUrl = computed(() => {
+	const paramClass = new URLSearchParams(normalizeEmbedParams(paramsForPreview.value));
+	if (paramClass.has('maxHeight')) {
+		const maxHeight = parseInt(paramClass.get('maxHeight')!);
+		paramClass.set('maxHeight', maxHeight === 0 ? '500' : Math.min(maxHeight, 700).toString()); // プレビューであまりにも縮小されると見づらいため、700pxまでに制限
+	}
+	return `${url}/embed/${props.entity}/${props.id}${paramClass.toString() ? '?' + paramClass.toString() : ''}`;
+});
+
+const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
+const header = ref(props.params?.header ?? true);
+const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500);
+
+const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
+const rounded = ref(props.params?.rounded ?? true);
+const border = ref(props.params?.border ?? true);
+
+function applyToPreview() {
+	const currentPreviewUrl = embedPreviewUrl.value;
+
+	paramsForPreview.value = {
+		header: header.value,
+		maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
+		colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
+		rounded: rounded.value,
+		border: border.value,
+	};
+
+	nextTick(() => {
+		if (currentPreviewUrl === embedPreviewUrl.value) {
+			// URLが変わらなくてもリロード
+			iframeEl.value?.contentWindow?.location.reload();
+		}
+	});
+}
+
+const result = ref('');
+
+function generate() {
+	result.value = getEmbedCode(`/embed/${props.entity}/${props.id}`, paramsForUrl.value);
+	phase.value = 'result';
+}
+
+function doCopy() {
+	copyToClipboard(result.value);
+	os.success();
+}
+//#endregion
+
+//#region プレビューのリサイズ
+const resizerRootEl = shallowRef<HTMLDivElement>();
+const iframeLoading = ref(true);
+const iframeEl = shallowRef<HTMLIFrameElement>();
+const iframeHeight = ref(0);
+const iframeScale = ref(1);
+const iframeStyle = computed(() => {
+	return `translate(-50%, -50%) scale(${iframeScale.value})`;
+});
+const resizeObserver = new ResizeObserver(() => {
+	calcScale();
+});
+
+function iframeOnLoad() {
+	iframeEl.value?.contentWindow?.addEventListener('beforeunload', () => {
+		iframeLoading.value = true;
+		nextTick(() => {
+			iframeHeight.value = 0;
+			iframeScale.value = 1;
+		});
+	});
+}
+
+function windowEventHandler(event: MessageEvent) {
+	if (event.source !== iframeEl.value?.contentWindow) {
+		return;
+	}
+	if (event.data.type === 'misskey:embed:ready') {
+		iframeEl.value!.contentWindow?.postMessage({
+			type: 'misskey:embedParent:registerIframeId',
+			payload: {
+				iframeId: 'embedCodeGen', // 同じタイミングで複数のembed iframeがある際の区別用なのでここではなんでもいい
+			},
+		});
+	}
+	if (event.data.type === 'misskey:embed:changeHeight') {
+		iframeHeight.value = event.data.payload.height;
+		nextTick(() => {
+			calcScale();
+			iframeLoading.value = false; // 初回の高さ変更まで待つ
+		});
+	}
+}
+
+function calcScale() {
+	if (!resizerRootEl.value) return;
+	const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ
+	const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ
+	const iframeWidth = 500;
+	const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大はしないので1を上限に
+	iframeScale.value = scale;
+}
+
+onMounted(() => {
+	window.addEventListener('message', windowEventHandler);
+	if (!resizerRootEl.value) return;
+	resizeObserver.observe(resizerRootEl.value);
+});
+
+function reset() {
+	window.removeEventListener('message', windowEventHandler);
+	resizeObserver.disconnect();
+
+	// プレビューのリセット
+	iframeHeight.value = 0;
+	iframeScale.value = 1;
+	iframeLoading.value = true;
+	result.value = '';
+	phase.value = 'input';
+}
+
+onDeactivated(() => {
+	reset();
+});
+
+onUnmounted(() => {
+	reset();
+});
+//#endregion
+</script>
+
+<style module>
+.transition_x_enterActive,
+.transition_x_leaveActive {
+	transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+	opacity: 0;
+	transform: translateX(50px);
+}
+.transition_x_leaveTo {
+	opacity: 0;
+	transform: translateX(-50px);
+}
+
+.embedCodeGenRoot {
+	container-type: inline-size;
+	height: 100%;
+}
+
+.embedCodeGenInputRoot {
+	height: 100%;
+	display: grid;
+	grid-template-columns: 1fr 400px;
+}
+
+.embedCodeGenPreviewRoot {
+	position: relative;
+	background-color: var(--bg);
+	cursor: not-allowed;
+}
+
+.embedCodeGenPreviewWrapper {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	pointer-events: none;
+	user-select: none;
+	-webkit-user-drag: none;
+}
+
+.embedCodeGenPreviewTitle {
+	position: absolute;
+	z-index: 100;
+	top: 8px;
+	left: 8px;
+	padding: 6px 10px;
+	border-radius: 6px;
+	font-size: 85%;
+}
+
+.embedCodeGenPreviewSpinner {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	pointer-events: none;
+	user-select: none;
+	-webkit-user-drag: none;
+}
+
+.embedCodeGenPreviewResizerRoot {
+	position: relative;
+	flex: 1 0;
+}
+
+.embedCodeGenPreviewResizer {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+}
+
+.embedCodeGenPreviewIframe {
+	display: block;
+	border: none;
+	width: 500px;
+	color-scheme: light dark;
+}
+
+.embedCodeGenSettings {
+	padding: 24px;
+	overflow-y: scroll;
+}
+
+.embedCodeGenResultRoot {
+	box-sizing: border-box;
+	padding: 24px;
+	height: 100%;
+	max-width: 700px;
+	margin: 0 auto;
+	display: flex;
+	align-items: center;
+}
+
+.embedCodeGenResultHeading {
+	text-align: center;
+	font-size: 1.2em;
+}
+
+.embedCodeGenResultHeadingIcon {
+	margin: 0 auto;
+	background-color: var(--accentedBg);
+	color: var(--accent);
+	text-align: center;
+	height: 64px;
+	width: 64px;
+	font-size: 24px;
+	line-height: 64px;
+	border-radius: 50%;
+}
+
+.embedCodeGenResultDescription {
+	text-align: center;
+	white-space: pre-wrap;
+}
+
+.embedCodeGenResultWrapper,
+.embedCodeGenResultCode {
+	width: 100%;
+}
+
+.embedCodeGenResultButtons {
+	margin: 0 auto;
+}
+
+@container (max-width: 800px) {
+	.embedCodeGenInputRoot {
+		grid-template-columns: 1fr;
+		grid-template-rows: 1fr 1fr;
+	}
+}
+</style>
diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue
index c13164c2968d11b50158c5f228fd9c954261003c..fca7aa2f4ec149a32ea5429583e9a1baac891c46 100644
--- a/packages/frontend/src/components/MkEmojiPicker.section.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.section.vue
@@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { ref, computed, Ref } from 'vue';
-import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
+import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js';
 import { i18n } from '@/i18n.js';
 import { customEmojis } from '@/custom-emojis.js';
 import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 4a3ed69f470d9cf2c132c14d0c5449bb3e3681d5..5ba175fc3504a9a03bd9b6146875b26d5254397f 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -117,7 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, shallowRef, computed, watch, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
-import XSection from '@/components/MkEmojiPicker.section.vue';
 import {
 	emojilist,
 	emojiCharByCategory,
@@ -126,7 +125,8 @@ import {
 	getEmojiName,
 	CustomEmojiFolderTree,
 	getUnicodeEmoji,
-} from '@/scripts/emojilist.js';
+} from '@@/js/emojilist.js';
+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';
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 8d301f16bd4e4e279d46af23b6cc802350f9d93b..eeecf052af239eb535d3d93d5c9ed36fc24123d2 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import DrawBlurhash from '@/workers/draw-blurhash?worker';
 import TestWebGL2 from '@/workers/test-webgl2?worker';
 import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js';
-import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js';
+import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
 
 const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
 	// テスト環境で Web Worker インスタンスは作成できない
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index e695564f921f748933750071edeaf83eadef4048..4c2fc1ba00da534e3e6d4cba9962c22c9d8c56aa 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
 import { debounce } from 'throttle-debounce';
 import MkButton from '@/components/MkButton.vue';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
 
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 2300802dcff86e595bceb43bb94402130207058e..4a4a99be253cfe85febb474ecd21580866f82adb 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -37,7 +37,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 { FILE_TYPE_BROWSERSAFE } from '@/const.js';
+import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
 import { defaultStore } from '@/store.js';
 import { focusParent } from '@/scripts/focus.js';
 
diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue
index f2f2bf47a8b59a1bf0d26be87ea8f10c7dd34b70..1b6f6cef31e45a25b60a37d063eda62d04ca7a27 100644
--- a/packages/frontend/src/components/MkMiniChart.vue
+++ b/packages/frontend/src/components/MkMiniChart.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { watch, ref } from 'vue';
 import { v4 as uuid } from 'uuid';
 import tinycolor from 'tinycolor2';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 
 const props = defineProps<{
 	src: number[];
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 4caafe54bf91fdd99bad78992ab95b9ff15251fe..2927a46977b6fb206cb6f9a715817bf2008c24be 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -627,7 +627,7 @@ function emitUpdReaction(emoji: string, delta: number) {
 	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
 	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
 	//content-visibility: auto;
-  //contain-intrinsic-size: 0 128px;
+	//contain-intrinsic-size: 0 128px;
 
 	&:focus-visible {
 		outline: none;
diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue
index 71b38d99ed9c92897842402b5ca269ce79634b8c..47a9c79e4551c2f72a97f51ea58a7d5f1ffa82a6 100644
--- a/packages/frontend/src/components/MkNotificationSelectWindow.vue
+++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue
@@ -35,7 +35,7 @@ import MkSwitch from './MkSwitch.vue';
 import MkInfo from './MkInfo.vue';
 import MkButton from './MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
-import { notificationTypes } from '@/const.js';
+import { notificationTypes } from '@@/js/const.js';
 import { i18n } from '@/i18n.js';
 
 type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 389987338d0eb789b88cc878eafb2bc96f213fa1..d67616e6b2036f834133a675d312a80ea22fa326 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -31,7 +31,7 @@ import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 import MkNote from '@/components/MkNote.vue';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { notificationTypes } from '@/const.js';
+import { notificationTypes } from '@@/js/const.js';
 import { infoImageUrl } from '@/instance.js';
 import { defaultStore } from '@/store.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index bd86b015916102e2f1aeec1f95f4c62e28cbac5e..8049f88051b8862a5b6f061214579a636835e7a5 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js';
 import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
 import { openingWindowsCount } from '@/os.js';
 import { claimAchievement } from '@/scripts/achievements.js';
-import { getScrollContainer } from '@/scripts/scroll.js';
+import { getScrollContainer } from '@@/js/scroll.js';
 import { useRouterFactory } from '@/router/supplier.js';
 import { mainRouter } from '@/router/main.js';
 
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 62a85389ad967b57a2693e21d96ad5607a2a62a3..d30f915c552394980fc11e21611b7f461abdeb30 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts">
 import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, 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';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js';
-import { useDocumentVisibility } from '@/scripts/use-document-visibility.js';
 import { defaultStore } from '@/store.js';
 import { MisskeyEntity } from '@/types/date-separated-list.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index 72bd8f4f6c4067c1efa28dd667a4c91d4a24109e..8e230cce4f7d2e488f9765363a7a8b0ea650be4e 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { sum } from '@/scripts/array.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { host } from '@/config.js';
-import { useInterval } from '@/scripts/use-interval.js';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
+import { useInterval } from '@@/js/use-interval.js';
 
 const props = defineProps<{
 	noteId: string;
@@ -83,10 +83,10 @@ if (props.poll.expiresAt) {
 }
 
 const vote = async (id) => {
-	pleaseLogin(undefined, pleaseLoginContext.value);
-
 	if (props.readOnly || closed.value || isVoted.value) return;
 
+	pleaseLogin(undefined, pleaseLoginContext.value);
+
 	const { canceled } = await os.confirm({
 		type: 'question',
 		text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
@@ -145,7 +145,7 @@ const vote = async (id) => {
 
 .done {
 	.choice {
-		cursor: default;
+		cursor: initial;
 	}
 }
 </style>
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index e0d0b561beb08f961fca6b1bb4a15928c6e66a4f..4fb4c6fe56a305cb1718bdaec4077cdaab9b9e8e 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
 import { i18n } from '@/i18n.js';
-import { getScrollContainer } from '@/scripts/scroll.js';
+import { getScrollContainer } from '@@/js/scroll.js';
 import { isHorizontalSwipeSwiping } from '@/scripts/touch.js';
 
 const SCROLL_STOP = 10;
diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue
index 60118fadd2a4f87c04f01afe2963d0cea949a664..3dd02b261c7fa09a3a8c6dddea848fffb3c35b0a 100644
--- a/packages/frontend/src/components/MkReactionsViewer.details.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.details.vue
@@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { } from 'vue';
+import { getEmojiName } from '@@/js/emojilist.js';
 import MkTooltip from './MkTooltip.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
-import { getEmojiName } from '@/scripts/emojilist.js';
 
 defineProps<{
 	showing: boolean;
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 26223364ab8ea5ff01f7742215133ead1d59afe5..f42a0b3227f396c6a46d9cae2abc1fc52e841977 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -20,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, inject, onMounted, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import { getUnicodeEmoji } from '@@/js/emojilist.js';
 import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
 import XDetails from '@/components/MkReactionsViewer.details.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
@@ -34,7 +35,6 @@ 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 { getUnicodeEmoji } from '@/scripts/emojilist.js';
 
 const props = defineProps<{
 	reaction: string;
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 0eba8d6a9cb6553b7510d0860e02237649f15fee..360d697d7cd5b58e84ed7a883110e3c9b3f4bbfb 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import { MenuItem } from '@/types/menu.js';
 
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 781145e1bc4268da2c6c36b99a1bf0d1113ded63..dabbe974683778ec4934e1dc7d5b085a521b148b 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -66,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue';
 import { toUnicode } from 'punycode/';
 import * as Misskey from 'misskey-js';
 import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
+import { query, extractDomain } from '@@/js/url.js';
 import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
 import MkButton from '@/components/MkButton.vue';
@@ -74,7 +75,6 @@ import MkInfo from '@/components/MkInfo.vue';
 import { host as configHost } from '@/config.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { query, extractDomain } from '@/scripts/url.js';
 import { login } from '@/account.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index ee224dba495cccea96996f7098b3d60eb9d0e6fc..35c07bc80ca3ae7505af3121c38eac64a9bdf3c5 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { watch, ref, computed } from 'vue';
 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 { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js';
 import { acct, userPage } from '@/filters/user.js';
 import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
 import { defaultStore } from '@/store.js';
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
index fa780d4ad369ac8fb6d0f080a4658b7e8040359e..fc3745c0090d4330b6bcb21d145aba17c11dee51 100644
--- a/packages/frontend/src/components/global/MkEmoji.vue
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -10,9 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, inject } from 'vue';
-import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@/scripts/emoji-base.js';
+import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js';
+import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js';
 import { defaultStore } from '@/store.js';
-import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import * as sound from '@/scripts/sound.js';
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index 0d869892bdd288fb3bfc37e0d5eea62b77798bad..ea1f3e2988f128cfd2b759435b95cc2ad83d4f9e 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -19,8 +19,13 @@ import MkSparkle from '@/components/MkSparkle.vue';
 import MkA, { MkABehavior } from '@/components/global/MkA.vue';
 import { host } from '@/config.js';
 import { defaultStore } from '@/store.js';
-import { nyaize as doNyaize } from '@/scripts/nyaize.js';
-import { safeParseFloat } from '@/scripts/safe-parse.js';
+
+function safeParseFloat(str: unknown): number | null {
+	if (typeof str !== 'string' || str === '') return null;
+	const num = parseFloat(str);
+	if (isNaN(num)) return null;
+	return num;
+}
 
 const QUOTE_STYLE = `
 display: block;
@@ -86,7 +91,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 			case 'text': {
 				let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
 				if (!disableNyaize && shouldNyaize) {
-					text = doNyaize(text);
+					text = Misskey.nyaize(text);
 				}
 
 				if (!props.plain) {
@@ -281,14 +286,14 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 							const child = token.children[0];
 							let text = child.type === 'text' ? child.props.text : '';
 							if (!disableNyaize && shouldNyaize) {
-								text = doNyaize(text);
+								text = Misskey.nyaize(text);
 							}
 							return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
 						} else {
 							const rt = token.children.at(-1)!;
 							let text = rt.type === 'text' ? rt.props.text : '';
 							if (!disableNyaize && shouldNyaize) {
-								text = doNyaize(text);
+								text = Misskey.nyaize(text);
 							}
 							return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
 						}
@@ -400,7 +405,6 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 			}
 
 			case 'emojiCode': {
-				// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 				if (props.author?.host == null) {
 					return [h(MkCustomEmoji, {
 						key: Math.random(),
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index f16d951679c37a3833fb8224e9316fc56405a790..f1a451808f443fbf6b482bbba8c7863c22ff8b95 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue';
 import tinycolor from 'tinycolor2';
 import XTabs, { Tab } from './MkPageHeader.tabs.vue';
-import { scrollToTop } from '@/scripts/scroll.js';
+import { scrollToTop } from '@@/js/scroll.js';
 import { globalEvents } from '@/events.js';
 import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
 import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index b12dc8cb31ad864b5249a694a5baf1bf00e6d0b9..3f37354908111ff4e4317194fd3bceb17be6f00a 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue';
 
-import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const.js';
+import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
 
 const rootEl = shallowRef<HTMLElement>();
 const headerEl = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index d2ddd4aa85225e3dc2976bfb0b0c516ea329654e..8f4e3b853a3ad61d51b91f29b725354d2fe15ab2 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -30,10 +30,17 @@ import { toUnicode as decodePunycode } from 'punycode/';
 import { url as local } from '@/config.js';
 import * as os from '@/os.js';
 import { useTooltip } from '@/scripts/use-tooltip.js';
-import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { MkABehavior } from '@/components/global/MkA.vue';
 
+function safeURIDecode(str: string): string {
+	try {
+		return decodeURIComponent(str);
+	} catch {
+		return str;
+	}
+}
+
 const props = withDefaults(defineProps<{
 	url: string;
 	rel?: string;
diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts
index 9da3582e1a616dc4fbb34b197a82a9fed2658007..0d03282cee22c8f4fced75f2d4eb5eb756bbe7a7 100644
--- a/packages/frontend/src/custom-emojis.ts
+++ b/packages/frontend/src/custom-emojis.ts
@@ -6,7 +6,6 @@
 import { shallowRef, computed, markRaw, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { useStream } from '@/stream.js';
 import { get, set } from '@/scripts/idb-proxy.js';
 
 const storageCache = await get('emojis');
@@ -29,23 +28,20 @@ watch(customEmojis, emojis => {
 	}
 }, { immediate: true });
 
-// TODO: ここら辺副作用なのでいい感じにする
-const stream = useStream();
-
-stream.on('emojiAdded', emojiData => {
-	customEmojis.value = [emojiData.emoji, ...customEmojis.value];
+export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple) {
+	customEmojis.value = [emoji, ...customEmojis.value];
 	set('emojis', customEmojis.value);
-});
+}
 
-stream.on('emojiUpdated', emojiData => {
-	customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.EmojiSimple ?? item);
+export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) {
+	customEmojis.value = customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item);
 	set('emojis', customEmojis.value);
-});
+}
 
-stream.on('emojiDeleted', emojiData => {
-	customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name));
+export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) {
+	customEmojis.value = customEmojis.value.filter(item => !emojis.some(search => search.name === item.name));
 	set('emojis', customEmojis.value);
-});
+}
 
 export async function fetchCustomEmojis(force = false) {
 	const now = Date.now();
diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts
index f200f242ed23621d44a5d91a697b4039269762b0..615dd99fa8d4d384520b81eb78efc03762d3e293 100644
--- a/packages/frontend/src/directives/follow-append.ts
+++ b/packages/frontend/src/directives/follow-append.ts
@@ -4,7 +4,7 @@
  */
 
 import { Directive } from 'vue';
-import { getScrollContainer, getScrollPosition } from '@/scripts/scroll.js';
+import { getScrollContainer, getScrollPosition } from '@@/js/scroll.js';
 
 export default {
 	mounted(src, binding, vn) {
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts
index 10d6adbcd0ca9d203c748505aa108a8fc401ccc9..17e787f9fc17df7e6d481466686b5be930cead0a 100644
--- a/packages/frontend/src/i18n.ts
+++ b/packages/frontend/src/i18n.ts
@@ -4,11 +4,11 @@
  */
 
 import { markRaw } from 'vue';
+import { I18n } from '@@/js/i18n.js';
 import type { Locale } from '../../../locales/index.js';
 import { locale } from '@/config.js';
-import { I18n } from '@/scripts/i18n.js';
 
-export const i18n = markRaw(new I18n<Locale>(locale));
+export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
 
 export function updateI18n(newLocale: Locale) {
 	i18n.locale = newLocale;
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index 6847321d6c40d992191eef107ba740e2cf905998..71cb42b30c45f0840f5594ab97c43217321b51e5 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -7,7 +7,7 @@ import { computed, reactive } from 'vue';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/scripts/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 '@/const.js';
+import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js';
 
 // TODO: 他のタブと永続化されたstateを同期
 
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 8029bca68d53030af27b2f190a59c9de43bcca9a..5b8ba77e012169076844ab60d5c95aa63db3e7a0 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-type Keys =
+export type Keys =
 	'v' |
 	'lastVersion' |
 	'instance' |
@@ -38,12 +38,22 @@ type Keys =
 	`aiscript:${string}` |
 	'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
 	'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~);
-	`channelLastReadedAt:${string}`
+	`channelLastReadedAt:${string}` |
+	`idbfallback::${string}`
+
+// セッション毎に廃棄されるLocalStorage代替(セーフモードなどで使用できそう)
+//const safeSessionStorage = new Map<Keys, string>();
 
 export const miLocalStorage = {
-	getItem: (key: Keys): string | null => window.localStorage.getItem(key),
-	setItem: (key: Keys, value: string): void => window.localStorage.setItem(key, value),
-	removeItem: (key: Keys): void => window.localStorage.removeItem(key),
+	getItem: (key: Keys): string | null => {
+		return window.localStorage.getItem(key);
+	},
+	setItem: (key: Keys, value: string): void => {
+		window.localStorage.setItem(key, value);
+	},
+	removeItem: (key: Keys): void => {
+		window.localStorage.removeItem(key);
+	},
 	getItemAsJson: (key: Keys): any | undefined => {
 		const item = miLocalStorage.getItem(key);
 		if (item === null) {
@@ -51,5 +61,7 @@ export const miLocalStorage = {
 		}
 		return JSON.parse(item);
 	},
-	setItemAsJson: (key: Keys, value: any): void => window.localStorage.setItem(key, JSON.stringify(value)),
+	setItemAsJson: (key: Keys, value: any): void => {
+		miLocalStorage.setItem(key, JSON.stringify(value));
+	},
 };
diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts
index 6a8ea09ed6af93d1bbef30fbfbec04f88f41c48b..25f853453a0dd67fbaec57742d325c453b9e9d8f 100644
--- a/packages/frontend/src/nirax.ts
+++ b/packages/frontend/src/nirax.ts
@@ -7,7 +7,14 @@
 
 import { Component, onMounted, shallowRef, ShallowRef } from 'vue';
 import { EventEmitter } from 'eventemitter3';
-import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
+
+function safeURIDecode(str: string): string {
+	try {
+		return decodeURIComponent(str);
+	} catch {
+		return str;
+	}
+}
 
 interface RouteDefBase {
 	path: string;
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index b88f078598ed33c1d5f8ed75a922fe5643ace416..d22e078c2a82fe786127e8627c4adfe4bf7ddf1f 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue';
 import tinycolor from 'tinycolor2';
 import { popupMenu } from '@/os.js';
-import { scrollToTop } from '@/scripts/scroll.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';
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
index a09db2a6d52805ca1e54a26a4ae9b16850f401cd..292e2e1dbc9f94f098eac66e5214508348bc357e 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 { misskeyApi } from '@/scripts/misskey-api.js';
 import * as Misskey from 'misskey-js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import { defaultStore } from '@/store.js';
 
diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue
index a7dd4c0a485fdaa582f76f83a8d92e41bd5ad253..8c9d7a8197a23a6e749959b9aef34326c04e4d46 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 { misskeyApi } from '@/scripts/misskey-api.js';
 import * as Misskey from 'misskey-js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import { defaultStore } from '@/store.js';
 
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 3e948abdf13d2484bbc405af7e7ae9d67dcf3f5d..b0137abb3f86462fb9e21aeedd07414a05626ddf 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -608,7 +608,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkRange from '@/components/MkRange.vue';
 import FormSlot from '@/components/form/slot.vue';
 import { i18n } from '@/i18n.js';
-import { ROLE_POLICIES } from '@/const.js';
+import { ROLE_POLICIES } from '@@/js/const.js';
 import { instance } from '@/instance.js';
 import { deepClone } from '@/scripts/clone.js';
 
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 6fb950494b2ea9bbcee322b870f6e671888815ca..7e29f6e0d8bbc155e083612f0024518390a064d6 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -253,7 +253,7 @@ import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { instance, fetchInstance } from '@/instance.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
-import { ROLE_POLICIES } from '@/const.js';
+import { ROLE_POLICIES } from '@@/js/const.js';
 import { useRouter } from '@/router/supplier.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index ea64e457e376f77ae4ef2148b811bdfa60449e62..22c5231dd94f48a02116bbbdcf03ca6e116a525e 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkTimeline from '@/components/MkTimeline.vue';
-import { scroll } from '@/scripts/scroll.js';
+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';
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index fb984de368a6690d05ba6e055199c69ab8ab6b93..aad6acb4b5055b4af74a2204d53d17ff043c90cb 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -44,6 +44,7 @@ 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';
 
 const props = defineProps<{
 	clipId: string,
@@ -127,21 +128,33 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
 		clipsCache.delete();
 	},
 }, ...(clip.value.isPublic ? [{
-	icon: 'ti ti-link',
-	text: i18n.ts.copyUrl,
-	handler: async (): Promise<void> => {
-		copyToClipboard(`${url}/clips/${clip.value.id}`);
-		os.success();
-	},
-}] : []), ...(clip.value.isPublic && isSupportShare() ? [{
 	icon: 'ti ti-share',
 	text: i18n.ts.share,
-	handler: async (): Promise<void> => {
-		navigator.share({
-			title: clip.value.name,
-			text: clip.value.description,
-			url: `${url}/clips/${clip.value.id}`,
-		});
+	handler: (ev: MouseEvent): void => {
+		os.popupMenu([{
+			icon: 'ti ti-link',
+			text: i18n.ts.copyUrl,
+			action: () => {
+				copyToClipboard(`${url}/clips/${clip.value!.id}`);
+				os.success();
+			},
+		}, {
+			icon: 'ti ti-code',
+			text: i18n.ts.genEmbedCode,
+			action: () => {
+				genEmbedCode('clips', clip.value!.id);
+			},
+		}, ...(isSupportShare() ? [{
+			icon: 'ti ti-share',
+			text: i18n.ts.share,
+			action: async () => {
+				navigator.share({
+					title: clip.value!.name,
+					text: clip.value!.description ?? '',
+					url: `${url}/clips/${clip.value!.id}`,
+				});
+			},
+		}] : [])], ev.currentTarget ?? ev.target);
 	},
 }] : []), {
 	icon: 'ti ti-trash',
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index 3026d00a2cde106ede0a0b370c2ac58b0cde3f0b..ffedaf27bfecbbb293572eb68b5c7cdfebad54e9 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -99,12 +99,12 @@ const file = ref<Misskey.entities.DriveFile>();
 const folderHierarchy = computed(() => {
 	if (!file.value) return [i18n.ts.drive];
 	const folderNames = [i18n.ts.drive];
-	
+
 	function get(folder: Misskey.entities.DriveFolder) {
 		if (folder.parent) get(folder.parent);
 		folderNames.push(folder.name);
 	}
-	
+
 	if (file.value.folder) get(file.value.folder);
 	return folderNames;
 });
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index 0f0b7e1ea8e67eba6767f643648f9e586c22c571..b5e49021264896cc9fdeb7b50e33a00514736ca2 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -205,7 +205,7 @@ import { claimAchievement } from '@/scripts/achievements.js';
 import { defaultStore } from '@/store.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { apiUrl } from '@/config.js';
 import { $i } from '@/account.js';
 import * as sound from '@/scripts/sound.js';
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
index 28f583829653684e0053c8757ded31b24652dba4..bd93fc8369659ef96d04c9ee915e24a6a7a84302 100644
--- a/packages/frontend/src/pages/notifications.vue
+++ b/packages/frontend/src/pages/notifications.vue
@@ -30,7 +30,7 @@ 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 { notificationTypes } from '@/const.js';
+import { notificationTypes } from '@@/js/const.js';
 
 const tab = ref('all');
 const includeTypes = ref<string[] | null>(null);
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 7d9cefa5c9f44e7d2b91806967ac4289c0b910f3..578fd65ba1a021df977dfcbfc94332ec8794e9ea 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -149,7 +149,7 @@ 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 { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { signinRequired } from '@/account.js';
 import { url } from '@/config.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index 97a793753d8b18598e30513b90798fd6ecd29535..a25595e8848dff40374839424820023ed5417774 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -22,7 +22,7 @@ import { useRouter } from '@/router/supplier.js';
 import * as os from '@/os.js';
 import { url } from '@/config.js';
 import { i18n } from '@/i18n.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue
index 51a03e441801df034807441aca575e3780ed9432..d823861b4ac634e73c3f5fa41cc69aafb8afb7d4 100644
--- a/packages/frontend/src/pages/reversi/index.vue
+++ b/packages/frontend/src/pages/reversi/index.vue
@@ -117,7 +117,7 @@ import { $i } from '@/account.js';
 import MkPagination from '@/components/MkPagination.vue';
 import { useRouter } from '@/router/supplier.js';
 import * as os from '@/os.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import * as sound from '@/scripts/sound.js';
 
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 70db6a5109649cb4d569e65b4e0414ab36df3954..cce671a7cb026c9095cbf824e5bfd32bed84fd04 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -69,7 +69,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
-import { notificationTypes } from '@/const.js';
+import { notificationTypes } from '@@/js/const.js';
 
 const $i = signinRequired();
 
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
index 9b77392872fbfee9ee137ee83002ae335cba9c74..1b3e1ecaee67752060cf0ed14ac5e6852c2d481c 100644
--- a/packages/frontend/src/pages/tag.vue
+++ b/packages/frontend/src/pages/tag.vue
@@ -28,6 +28,7 @@ import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
 import { defaultStore } from '@/store.js';
 import * as os from '@/os.js';
+import { genEmbedCode } from '@/scripts/get-embed-code.js';
 
 const props = defineProps<{
 	tag: string;
@@ -51,7 +52,19 @@ async function post() {
 	notes.value?.pagingComponent?.reload();
 }
 
-const headerActions = computed(() => []);
+const headerActions = computed(() => [{
+	icon: 'ti ti-dots',
+	label: i18n.ts.more,
+	handler: (ev: MouseEvent) => {
+		os.popupMenu([{
+			text: i18n.ts.genEmbedCode,
+			icon: 'ti ti-code',
+			action: () => {
+				genEmbedCode('tags', props.tag);
+			},
+		}], ev.currentTarget ?? ev.target);
+	}
+}]);
 
 const headerTabs = computed(() => []);
 
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index 50c3beeabc3bda98bcef06013fbde6eb67721132..fe7896b7d955b9d40a23209c54db7930c152e455 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -79,6 +79,8 @@ 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 MkButton from '@/components/MkButton.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -86,8 +88,6 @@ import MkFolder from '@/components/MkFolder.vue';
 
 import { $i } from '@/account.js';
 import { Theme, applyTheme } from '@/scripts/theme.js';
-import lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
 import { host } from '@/config.js';
 import * as os from '@/os.js';
 import { ColdDeviceStorage, defaultStore } from '@/store.js';
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index d5943e8fbc616f9f1791ac6d605718ec649720bb..cc1ed3d01f29de94d268457a42aa8264571498c3 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -40,7 +40,7 @@ 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 '@/scripts/scroll.js';
+import { scroll } from '@@/js/scroll.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { defaultStore } from '@/store.js';
@@ -54,7 +54,7 @@ import { 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';
-	
+
 provide('shouldOmitHeaderTitle', true);
 
 const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index de6737f37d82ca8c00d8b034c07839b79a84b618..31a3f1b0607b306236f66c2d276281617d81f5ae 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkTimeline from '@/components/MkTimeline.vue';
-import { scroll } from '@/scripts/scroll.js';
+import { scroll } from '@@/js/scroll.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 3039ec749987b20843a8dca2529c636d0d68f553..8e0292c7fe3835756dd345827239d5f9fd9e3622 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -161,7 +161,7 @@ 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 { getScrollPosition } from '@/scripts/scroll.js';
+import { getScrollPosition } from '@@/js/scroll.js';
 import { getUserMenu } from '@/scripts/get-user-menu.js';
 import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index db326f9e6cf489252708c0f8380547c5160f01ae..732d483615bf10edb08e18a8c5e4453066d1e84d 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -24,7 +24,7 @@ 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 { getScrollContainer } from '@/scripts/scroll.js';
+import { getScrollContainer } from '@@/js/scroll.js';
 
 const notes = ref<Misskey.entities.Note[]>([]);
 const isScrolling = ref(false);
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 995a2055b85090dbca6f04f86d633af6c0e24a04..8a29fd677ec9a11d2a356eb5d265f49810b60bfe 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -3,15 +3,14 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { App, AsyncComponentLoader, defineAsyncComponent, provide } from 'vue';
-import type { RouteDef } from '@/nirax.js';
-import { IRouter, Router } from '@/nirax.js';
+import { AsyncComponentLoader, defineAsyncComponent } from 'vue';
+import type { IRouter, RouteDef } from '@/nirax.js';
+import { Router } from '@/nirax.js';
 import { $i, iAmModerator } from '@/account.js';
 import MkLoading from '@/pages/_loading_.vue';
 import MkError from '@/pages/_error_.vue';
-import { setMainRouter } from '@/router/main.js';
 
-const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
+export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
 	loader: loader,
 	loadingComponent: MkLoading,
 	errorComponent: MkError,
@@ -240,7 +239,7 @@ const routes: RouteDef[] = [{
 		origin: 'origin',
 	},
 }, {
-	// Legacy Compatibility	
+	// Legacy Compatibility
 	path: '/authorize-follow',
 	redirect: '/lookup',
 	loginRequired: true,
@@ -597,32 +596,6 @@ const routes: RouteDef[] = [{
 	component: page(() => import('@/pages/not-found.vue')),
 }];
 
-function createRouterImpl(path: string): IRouter {
+export function createMainRouter(path: string): IRouter {
 	return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue')));
 }
-
-/**
- * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
- * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能)
- */
-export function setupRouter(app: App) {
-	app.provide('routerFactory', createRouterImpl);
-
-	const mainRouter = createRouterImpl(location.pathname + location.search + location.hash);
-
-	window.addEventListener('popstate', (event) => {
-		mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key);
-	});
-
-	mainRouter.addListener('push', ctx => {
-		window.history.pushState({ key: ctx.key }, '', ctx.path);
-	});
-
-	mainRouter.addListener('replace', ctx => {
-		window.history.replaceState({ key: ctx.key }, '', ctx.path);
-	});
-
-	mainRouter.init();
-
-	setMainRouter(mainRouter);
-}
diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts
index 7a3fde131ed840139b459b4ad1148a7bb8e367d8..6ee967e6f443a2be54c6afca33d38835fab7811a 100644
--- a/packages/frontend/src/router/main.ts
+++ b/packages/frontend/src/router/main.ts
@@ -3,10 +3,37 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { ShallowRef } from 'vue';
 import { EventEmitter } from 'eventemitter3';
 import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js';
 
+import type { App, ShallowRef } from 'vue';
+
+/**
+ * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
+ * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能)
+ */
+export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)): void {
+	app.provide('routerFactory', routerFactory);
+
+	const mainRouter = routerFactory(location.pathname + location.search + location.hash);
+
+	window.addEventListener('popstate', (event) => {
+		mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key);
+	});
+
+	mainRouter.addListener('push', ctx => {
+		window.history.pushState({ key: ctx.key }, '', ctx.path);
+	});
+
+	mainRouter.addListener('replace', ctx => {
+		window.history.replaceState({ key: ctx.key }, '', ctx.path);
+	});
+
+	mainRouter.init();
+
+	setMainRouter(mainRouter);
+}
+
 function getMainRouter(): IRouter {
 	const router = mainRouterHolder;
 	if (!router) {
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
index 98a0c61752cb66c446dd5b552f18795e218e4901..417ba08c3fa4223d6038fe7d9bd84de3a6b380b5 100644
--- a/packages/frontend/src/scripts/aiscript/api.ts
+++ b/packages/frontend/src/scripts/aiscript/api.ts
@@ -4,13 +4,13 @@
  */
 
 import { utils, values } from '@syuilo/aiscript';
+import * as Misskey from 'misskey-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';
 import { url, lang } from '@/config.js';
-import { nyaize } from '@/scripts/nyaize.js';
 
 export function aiScriptReadline(q: string): Promise<string> {
 	return new Promise(ok => {
@@ -87,7 +87,7 @@ export function createAiScriptEnv(opts) {
 		}),
 		'Mk:nyaize': values.FN_NATIVE(([text]) => {
 			utils.assertString(text);
-			return values.STR(nyaize(text.value));
+			return values.STR(Misskey.nyaize(text.value));
 		}),
 	};
 }
diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts
index 8fc857f84fce06babd2417ad944cc96222e96322..c3c3f419a978c064f94107e1de0421813dba6add 100644
--- a/packages/frontend/src/scripts/check-reaction-permissions.ts
+++ b/packages/frontend/src/scripts/check-reaction-permissions.ts
@@ -4,7 +4,7 @@
  */
 
 import * as Misskey from 'misskey-js';
-import { UnicodeEmojiDef } from './emojilist.js';
+import { 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絵文字であることには変わりないので常にリアクション可能とする;
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
index e94027d3022a516e4e08d28b3a1c9743347b380b..b0ffac93d70e76e65888419af81dd460d8609c0c 100644
--- a/packages/frontend/src/scripts/code-highlighter.ts
+++ b/packages/frontend/src/scripts/code-highlighter.ts
@@ -7,13 +7,13 @@ import { getHighlighterCore, loadWasm } from 'shiki/core';
 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 { 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 lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
 
 let _highlighter: HighlighterCore | null = null;
 
diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts
index eb2da5ad8665c47ebe6c7d55b7c8f9684a79c407..81278b17ea6de36820ae97f5ae45412fc2ab556a 100644
--- a/packages/frontend/src/scripts/focus.ts
+++ b/packages/frontend/src/scripts/focus.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js';
+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;
diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/scripts/get-embed-code.ts
new file mode 100644
index 0000000000000000000000000000000000000000..007cd6561b5c998cde914f9c745e5d957b780405
--- /dev/null
+++ b/packages/frontend/src/scripts/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 '@/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
index b5d7350a41b6b137702fcfd667fa34572930a681..e0ccea813dc3a37f294f6edc5ebfeee6e2e72356 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -21,6 +21,7 @@ import { MenuItem } from '@/types/menu.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';
 
 export async function getNoteClipMenu(props: {
 	note: Misskey.entities.Note;
@@ -156,6 +157,19 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string):
 	};
 }
 
+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>;
@@ -310,7 +324,7 @@ export function getNoteMenu(props: {
 				action: () => {
 					window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
 				},
-			} : undefined,
+			} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode),
 			...(isSupportShare() ? [{
 				icon: 'ti ti-share',
 				text: i18n.ts.share,
@@ -443,14 +457,14 @@ export function getNoteMenu(props: {
 			icon: 'ti ti-copy',
 			text: i18n.ts.copyContent,
 			action: copyContent,
-		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
-		, (appearNote.url || appearNote.uri) ? {
+		}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink),
+		(appearNote.url || appearNote.uri) ? {
 			icon: 'ti ti-external-link',
 			text: i18n.ts.showOnRemote,
 			action: () => {
 				window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
 			},
-		} : undefined]
+		} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)]
 			.filter(x => x !== undefined);
 	}
 
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 33f16a68aa618d4e8c6c8ae9b3b03b0324d1b20c..035abc7bd061f642d058d0f77abc811bffe53642 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -17,6 +17,7 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-pe
 import { 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 { MenuItem } from '@/types/menu.js';
 
 export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
@@ -179,7 +180,17 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 			if (user.url == null) return;
 			window.open(user.url, '_blank', 'noopener');
 		},
-	}] : []), {
+	}] : [{
+		icon: 'ti ti-code',
+		text: i18n.ts.genEmbedCode,
+		type: 'parent' as const,
+		children: [{
+			text: i18n.ts.noteOfThisUser,
+			action: () => {
+				genEmbedCode('user-timeline', user.id);
+			},
+		}], // TODO: ユーザーカードの埋め込みなど
+	}]), {
 		icon: 'ti ti-share',
 		text: i18n.ts.copyProfileUrl,
 		action: () => {
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
index 6b511f2a5fc78ed8be89a74a393014984746f16f..20f51660c725ddc15a79b50d80a675ea0559477e 100644
--- a/packages/frontend/src/scripts/idb-proxy.ts
+++ b/packages/frontend/src/scripts/idb-proxy.ts
@@ -10,10 +10,11 @@ import {
 	set as iset,
 	del as idel,
 } from 'idb-keyval';
+import { miLocalStorage } from '@/local-storage.js';
 
-const fallbackName = (key: string) => `idbfallback::${key}`;
+const PREFIX = 'idbfallback::';
 
-let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true;
+let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
 
 // iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
 // バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
@@ -38,15 +39,15 @@ if (idbAvailable) {
 
 export async function get(key: string) {
 	if (idbAvailable) return iget(key);
-	return JSON.parse(window.localStorage.getItem(fallbackName(key)));
+	return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
 }
 
 export async function set(key: string, val: any) {
 	if (idbAvailable) return iset(key, val);
-	return window.localStorage.setItem(fallbackName(key), JSON.stringify(val));
+	return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
 }
 
 export async function del(key: string) {
 	if (idbAvailable) return idel(key);
-	return window.localStorage.removeItem(fallbackName(key));
+	return miLocalStorage.removeItem(`${PREFIX}${key}`);
 }
diff --git a/packages/frontend/src/scripts/is-link.ts b/packages/frontend/src/scripts/is-link.ts
new file mode 100644
index 0000000000000000000000000000000000000000..946f86400e16e75b4f4e73b460b98006363be0ec
--- /dev/null
+++ b/packages/frontend/src/scripts/is-link.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isLink(el: HTMLElement) {
+	if (el.tagName === 'A') return true;
+	if (el.parentElement) {
+		return isLink(el.parentElement);
+	}
+	return false;
+}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
index 099a22163af44a0e8c2e6c3444bda13e2706fada..68a5a1dcf886eaeee058c6336c28febe91b4aa34 100644
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ b/packages/frontend/src/scripts/media-proxy.ts
@@ -3,51 +3,32 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { query } from '@/scripts/url.js';
+import { MediaProxy } from '@@/js/media-proxy.js';
 import { url } from '@/config.js';
 import { instance } from '@/instance.js';
 
-export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
-	const localProxy = `${url}/proxy`;
+let _mediaProxy: MediaProxy | null = null;
 
-	if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
-		// もう既にproxyっぽそうだったらurlを取り出す
-		imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
+export function getProxiedImageUrl(...args: Parameters<MediaProxy['getProxiedImageUrl']>): string {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
 	}
 
-	return `${mustOrigin ? localProxy : instance.mediaProxy}/${
-		type === 'preview' ? 'preview.webp'
-		: 'image.webp'
-	}?${query({
-		url: imageUrl,
-		...(!noFallback ? { 'fallback': '1' } : {}),
-		...(type ? { [type]: '1' } : {}),
-		...(mustOrigin ? { origin: '1' } : {}),
-	})}`;
+	return _mediaProxy.getProxiedImageUrl(...args);
 }
 
-export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
-	if (imageUrl == null) return null;
-	return getProxiedImageUrl(imageUrl, type);
-}
-
-export function getStaticImageUrl(baseUrl: string): string {
-	const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url);
-
-	if (u.href.startsWith(`${url}/emoji/`)) {
-		// もう既にemojiっぽそうだったらsearchParams付けるだけ
-		u.searchParams.set('static', '1');
-		return u.href;
+export function getProxiedImageUrlNullable(...args: Parameters<MediaProxy['getProxiedImageUrlNullable']>): string | null {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
 	}
 
-	if (u.href.startsWith(instance.mediaProxy + '/')) {
-		// もう既にproxyっぽそうだったらsearchParams付けるだけ
-		u.searchParams.set('static', '1');
-		return u.href;
+	return _mediaProxy.getProxiedImageUrlNullable(...args);
+}
+
+export function getStaticImageUrl(...args: Parameters<MediaProxy['getStaticImageUrl']>): string {
+	if (_mediaProxy == null) {
+		_mediaProxy = new MediaProxy(instance, url);
 	}
 
-	return `${instance.mediaProxy}/static.webp?${query({
-		url: u.href,
-		static: '1',
-	})}`;
+	return _mediaProxy.getStaticImageUrl(...args);
 }
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
index 9938e534c139e72d0c57c748fbf3f10811158bdf..bf59fe98a0edb14cbe600df84d1952237e029820 100644
--- a/packages/frontend/src/scripts/mfm-function-picker.ts
+++ b/packages/frontend/src/scripts/mfm-function-picker.ts
@@ -6,7 +6,7 @@
 import { Ref, nextTick } from 'vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { MFM_TAGS } from '@/const.js';
+import { MFM_TAGS } from '@@/js/const.js';
 import type { MenuItem } from '@/types/menu.js';
 
 /**
diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts
index 1caa2dfc21017074e4233f2176e312e94d6a5f3a..ed49611b4f3798cd9723f146f17324bfcc4e9091 100644
--- a/packages/frontend/src/scripts/popout.ts
+++ b/packages/frontend/src/scripts/popout.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { appendQuery } from './url.js';
+import { appendQuery } from '@@/js/url.js';
 import * as config from '@/config.js';
 
 export function popout(path: string, w?: HTMLElement) {
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
index 31a9ac1ad9dca54f33892827884254d48bee010b..11b6f52ddd0db4fb7d6f4e0589422ed6babd27a8 100644
--- a/packages/frontend/src/scripts/post-message.ts
+++ b/packages/frontend/src/scripts/post-message.ts
@@ -18,7 +18,7 @@ export type MiPostMessageEvent = {
  * 親フレームにイベントを送信
  */
 export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
-	window.postMessage({
+	window.parent.postMessage({
 		type,
 		payload,
 	}, '*');
diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts
deleted file mode 100644
index 6bfcef6c362c208d4b3dbc4cbb3eb8a84e9c73d7..0000000000000000000000000000000000000000
--- a/packages/frontend/src/scripts/safe-parse.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function safeParseFloat(str: unknown): number | null {
-	if (typeof str !== 'string' || str === '') return null;
-	const num = parseFloat(str);
-	if (isNaN(num)) return null;
-	return num;
-}
diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts
deleted file mode 100644
index 0edf4e9eba0f32f6bded54812f4a71afb74a1379..0000000000000000000000000000000000000000
--- a/packages/frontend/src/scripts/safe-uri-decode.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function safeURIDecode(str: string): string {
-	try {
-		return decodeURIComponent(str);
-	} catch {
-		return str;
-	}
-}
diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cb0e607fcb6ad6a57e10a735ea8259696ef5ae9e
--- /dev/null
+++ b/packages/frontend/src/scripts/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/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index c7f8b3d59663c518e2a03f626d1f916531773f73..9b9f1f030c330d0027f07a4948d115191e1b63a6 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -5,11 +5,11 @@
 
 import { ref } from 'vue';
 import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
 import { deepClone } from './clone.js';
 import type { BundledTheme } from 'shiki/themes';
 import { globalEvents } from '@/events.js';
-import lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
 import { miLocalStorage } from '@/local-storage.js';
 
 export type Theme = {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 437314074a0c5a966e81a70574a61c685189c8ad..0bf499bb4d605d2a6fa47e7bb46a92bb68e063d0 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -458,10 +458,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
-  contextMenu: {
+	contextMenu: {
 		where: 'device',
 		default: 'app' as 'app' | 'appWithShift' | 'native',
-  },
+	},
 
 	sound_masterVolume: {
 		where: 'device',
@@ -520,8 +520,8 @@ interface Watcher {
 /**
  * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
  */
-import lightTheme from '@/themes/l-light.json5';
-import darkTheme from '@/themes/d-green-lime.json5';
+import lightTheme from '@@/themes/l-light.json5';
+import darkTheme from '@@/themes/d-green-lime.json5';
 
 export class ColdDeviceStorage {
 	public static default = {
@@ -558,7 +558,7 @@ export class ColdDeviceStorage {
 	public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void {
 		// 呼び出し側のバグ等で undefined が来ることがある
 		// undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		 
 		if (value === undefined) {
 			console.error(`attempt to store undefined value for key '${key}'`);
 			return;
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index 0d5bd78b09af845f20d1a99c3803833f9013c711..9d7edce890f6cb29925f7f7767f50e5a35b0288f 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -7,17 +7,20 @@ import * as Misskey from 'misskey-js';
 import { markRaw } from 'vue';
 import { $i } from '@/account.js';
 import { wsOrigin } from '@/config.js';
+// TODO: No WebsocketモードでStreamMockが使えそう
+//import { StreamMock } from '@/scripts/stream-mock.js';
 
 // heart beat interval in ms
 const HEART_BEAT_INTERVAL = 1000 * 60;
 
-let stream: Misskey.Stream | null = null;
-let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null;
+let stream: Misskey.IStream | null = null;
+let timeoutHeartBeat: number | null = null;
 let lastHeartbeatCall = 0;
 
-export function useStream(): Misskey.Stream {
+export function useStream(): Misskey.IStream {
 	if (stream) return stream;
 
+	// TODO: No Websocketモードもここで判定
 	stream = markRaw(new Misskey.Stream(wsOrigin, $i ? {
 		token: $i.token,
 	} : null));
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
index 8dad6666235daac237d61f9b00e6b511087e0366..e234bb3a33a0393cad69fecbd24786ad33956625 100644
--- a/packages/frontend/src/ui/_common_/statusbar-federation.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -35,7 +35,7 @@ import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MarqueeText from '@/components/MkMarquee.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index 6e1d06eec1f99e620334bd66e055c4f9bda66eee..550fc39b001bf57145da603baa7ca3844b020279 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MarqueeText from '@/components/MkMarquee.vue';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { shuffle } from '@/scripts/shuffle.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
index 67f8b109c484a6d15855b510867852e0cd05e64e..078b595dca744b2bb830746cf4c9fa2fc238fb15 100644
--- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -35,7 +35,7 @@ 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 { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { getNoteSummary } from '@/scripts/get-note-summary.js';
 import { notePage } from '@/filters/note.js';
 
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index 79c967191709a6e7fb635c4a1d31cbe490a79f37..e7ecf7fd2022f6c949d6edc17c65919e8d8eee27 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -26,7 +26,7 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
 import { useScrollPositionManager } from '@/nirax.js';
-import { getScrollContainer } from '@/scripts/scroll.js';
+import { getScrollContainer } from '@@/js/scroll.js';
 import { mainRouter } from '@/router/main.js';
 
 defineProps<{
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 073acbd4db7293eff1593fd43e0b4095072b57b5..00a6811fc98d5177340796c198809e7633d2c67b 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -108,7 +108,7 @@ import { $i } from '@/account.js';
 import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { CURRENT_STICKY_BOTTOM } from '@/const.js';
+import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
 import { useScrollPositionManager } from '@/nirax.js';
 import { mainRouter } from '@/router/main.js';
 
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 49fd103d37eb60f8ce8ed72ff2f7bcac741b6ab8..bcfaaf00ab4ff10060ae21a5c0af7131bee5370a 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -25,11 +25,11 @@ 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, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
 import { i18n } from '@/i18n.js';
 import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/account.js';
diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue
index 6ece33eff3477e9a29cfc734209d035065b65c42..412d5278192d6a45a1c0a49b50d656cdd696a167 100644
--- a/packages/frontend/src/widgets/WidgetCalendar.vue
+++ b/packages/frontend/src/widgets/WidgetCalendar.vue
@@ -42,7 +42,7 @@ import { ref } from 'vue';
 import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import { GetFormResultType } from '@/scripts/form.js';
 import { i18n } from '@/i18n.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 
 const name = 'calendar';
 
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue
index ed907de9b8db0abf90b92dca7a6fa89a68cfca61..c10416e4fbf0c7c96db6df9f2a4c766cb39e6953 100644
--- a/packages/frontend/src/widgets/WidgetFederation.vue
+++ b/packages/frontend/src/widgets/WidgetFederation.vue
@@ -32,7 +32,7 @@ import { 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 '@/scripts/use-interval.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';
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index 76ccdb397119a7e5516763a49008fd34b6e825e2..d090372b9a26a9cd31317a21d9e0322288655795 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -26,7 +26,7 @@ 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 { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
 
 const name = 'instanceCloud';
diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
index 5c89a06c62062dd03f92c41d4ce768d09b3a1049..d56ee96ac1751e374eb1cbaefa35e1a03f5a062c 100644
--- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue
+++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
@@ -18,7 +18,7 @@ import { ref } from 'vue';
 import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import { GetFormResultType } from '@/scripts/form.js';
 import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.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/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index e5758662cc49479c8ef5b79ff5b8f8bbb7d1a2ce..13f5a4802a75ecc2d221dc6cf0a5754b9c947d33 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -30,7 +30,7 @@ import { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { url as base } from '@/config.js';
 import { i18n } from '@/i18n.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { infoImageUrl } from '@/instance.js';
 
 const name = 'rss';
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index 16306ef5ba9b39ca8fed919115d266aba9a1f576..51f1cac97f39df14702f824877f50018181c1ba3 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -35,7 +35,7 @@ import { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { shuffle } from '@/scripts/shuffle.js';
 import { url as base } from '@/config.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 
 const name = 'rssTicker';
 
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index b8efd3bda9fc7808ab0a5bfe339da3b34d444b22..3fea1d70534429af206a158e11e926fe0fe505e8 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -23,7 +23,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid
 import { GetFormResultType } from '@/scripts/form.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'slideshow';
diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue
index 4299181a273f93c23758e6cd3325bf12df0c1377..a41db513e8d1eeca371bd557d5bef3d87657b1a5 100644
--- a/packages/frontend/src/widgets/WidgetTrends.vue
+++ b/packages/frontend/src/widgets/WidgetTrends.vue
@@ -31,7 +31,7 @@ import { 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 '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import { defaultStore } from '@/store.js';
 
diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue
index d9f4dc49ea87aeeb621e6e111470383f3c4d184c..72391d622eeb3b470f74bd2362c23acd3478d48f 100644
--- a/packages/frontend/src/widgets/WidgetUserList.vue
+++ b/packages/frontend/src/widgets/WidgetUserList.vue
@@ -31,7 +31,7 @@ import { GetFormResultType } from '@/scripts/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 
diff --git a/packages/frontend/test/emoji.test.ts b/packages/frontend/test/emoji.test.ts
index 9a2989b37348794c5347efab6eb85d4cc23cb4f5..cf686efd0d127651a1aaadc142ce0be00087547e 100644
--- a/packages/frontend/test/emoji.test.ts
+++ b/packages/frontend/test/emoji.test.ts
@@ -6,7 +6,7 @@
 import { describe, test, assert, afterEach } from 'vitest';
 import { render, cleanup, type RenderResult } from '@testing-library/vue';
 import { defaultStoreState } from './init.js';
-import { getEmojiName } from '@/scripts/emojilist.js';
+import { getEmojiName } from '@@/js/emojilist.js';
 import { components } from '@/components/index.js';
 import { directives } from '@/directives/index.js';
 import MkEmoji from '@/components/global/MkEmoji.vue';
diff --git a/packages/frontend/test/i18n.test.ts b/packages/frontend/test/i18n.test.ts
index e1cab1f15fd2683a80bd2f66d119dc4157737e77..9d6cf855f340a91ae179cbd3846d50862065f1e5 100644
--- a/packages/frontend/test/i18n.test.ts
+++ b/packages/frontend/test/i18n.test.ts
@@ -4,9 +4,11 @@
  */
 
 import { describe, expect, it } from 'vitest';
-import { I18n } from '@/scripts/i18n.js';
+import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので
 import { ParameterizedString } from '../../../locales/index.js';
 
+// TODO: このテストはfrontend-sharedに移動する
+
 describe('i18n', () => {
 	it('t', () => {
 		const i18n = new I18n({
diff --git a/packages/frontend/test/scroll.test.ts b/packages/frontend/test/scroll.test.ts
index a0b56b7221bbadb5cd63b7af51e5bbf425072ad2..32a5a1c55811931b67d6e5c85de8cb6fd6b6f709 100644
--- a/packages/frontend/test/scroll.test.ts
+++ b/packages/frontend/test/scroll.test.ts
@@ -5,7 +5,7 @@
 
 import { describe, test, assert, afterEach } from 'vitest';
 import { Window } from 'happy-dom';
-import { onScrollBottom, onScrollTop } from '@/scripts/scroll.js';
+import { onScrollBottom, onScrollTop } from '@@/js/scroll.js';
 
 describe('Scroll', () => {
 	describe('onScrollTop', () => {
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
index fe4d20289492776252989f792ada957b8d70ce47..b88773b598c6e825afb26f7ce9ec96996b710a07 100644
--- a/packages/frontend/tsconfig.json
+++ b/packages/frontend/tsconfig.json
@@ -23,7 +23,8 @@
 		"useDefineForClassFields": true,
 		"baseUrl": ".",
 		"paths": {
-			"@/*": ["./src/*"]
+			"@/*": ["./src/*"],
+			"@@/*": ["../frontend-shared/*"]
 		},
 		"typeRoots": [
 			"./@types",
diff --git a/packages/frontend/vite.config.local-dev.ts b/packages/frontend/vite.config.local-dev.ts
index 887ab7927e9f84ad1866459282f547cd46487e55..922fb459958fed81f234be389106469c9d99a099 100644
--- a/packages/frontend/vite.config.local-dev.ts
+++ b/packages/frontend/vite.config.local-dev.ts
@@ -15,6 +15,7 @@ const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8'))
 
 const httpUrl = `http://localhost:${port}/`;
 const websocketUrl = `ws://localhost:${port}/`;
+const embedUrl = `http://localhost:5174/`;
 
 // activitypubリクエストはProxyを通し、それ以外はViteの開発サーバーを返す
 function varyHandler(req: IncomingMessage) {
@@ -50,6 +51,12 @@ const devConfig: UserConfig = {
 				ws: true,
 			},
 			'/favicon.ico': httpUrl,
+			'/robots.txt': httpUrl,
+			'/embed.js': httpUrl,
+			'/embed': {
+				target: embedUrl,
+				ws: true,
+			},
 			'/identicon': {
 				target: httpUrl,
 				rewrite(path) {
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 6decbc0ef70096915c73118f430113488410ab38..e982df8ffd7ee898336a9474b58417bea4b03acf 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -65,6 +65,9 @@ export function getConfig(): UserConfig {
 
 		server: {
 			port: 5173,
+			headers: { // なんか効かない
+				'X-Frame-Options': 'DENY',
+			},
 		},
 
 		plugins: [
@@ -87,6 +90,7 @@ export function getConfig(): UserConfig {
 			extensions,
 			alias: {
 				'@/': __dirname + '/src/',
+				'@@/': __dirname + '/../frontend-shared/',
 				'/client-assets/': __dirname + '/assets/',
 				'/static-assets/': __dirname + '/../backend/assets/',
 				'/fluent-emojis/': __dirname + '/../../fluent-emojis/dist/',
@@ -151,7 +155,7 @@ export function getConfig(): UserConfig {
 				},
 			},
 			cssCodeSplit: true,
-			outDir: __dirname + '/../../built/_vite_',
+			outDir: __dirname + '/../../built/_frontend_vite_',
 			assetsDir: '.',
 			emptyOutDir: false,
 			sourcemap: process.env.NODE_ENV === 'development',
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index aaaa0493ca493b53cb85f5374e664b13fb1f6084..1ec7f0ec7f392545a4da78352364ba1448f0e07a 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -551,7 +551,7 @@ type Channel = components['schemas']['Channel'];
 // Warning: (ae-forgotten-export) The symbol "AnyOf" needs to be exported by the entry point index.d.ts
 //
 // @public (undocumented)
-export abstract class ChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> {
+export abstract class ChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> {
     constructor(stream: Stream, channel: string, name?: string);
     // (undocumented)
     channel: string;
@@ -2119,6 +2119,24 @@ type IAuthorizedAppsResponse = operations['i___authorized-apps']['responses']['2
 // @public (undocumented)
 type IChangePasswordRequest = operations['i___change-password']['requestBody']['content']['application/json'];
 
+// @public (undocumented)
+export interface IChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> {
+    // (undocumented)
+    channel: string;
+    // (undocumented)
+    dispose(): void;
+    // (undocumented)
+    id: string;
+    // (undocumented)
+    inCount: number;
+    // (undocumented)
+    name?: string;
+    // (undocumented)
+    outCount: number;
+    // (undocumented)
+    send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void;
+}
+
 // @public (undocumented)
 type IClaimAchievementRequest = operations['i___claim-achievement']['requestBody']['content']['application/json'];
 
@@ -2281,6 +2299,40 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200
 // @public (undocumented)
 function isPureRenote(note: Note): note is PureRenote;
 
+// @public (undocumented)
+export interface IStream extends EventEmitter<StreamEvents> {
+    // (undocumented)
+    close(): void;
+    // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts
+    //
+    // (undocumented)
+    disconnectToChannel(connection: NonSharedConnection): void;
+    // (undocumented)
+    heartbeat(): void;
+    // (undocumented)
+    ping(): void;
+    // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts
+    //
+    // (undocumented)
+    removeSharedConnection(connection: SharedConnection): void;
+    // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts
+    //
+    // (undocumented)
+    removeSharedConnectionPool(pool: Pool): void;
+    // (undocumented)
+    send(typeOrPayload: string): void;
+    // (undocumented)
+    send(typeOrPayload: string, payload: unknown): void;
+    // (undocumented)
+    send(typeOrPayload: Record<string, unknown> | unknown[]): void;
+    // (undocumented)
+    send(typeOrPayload: string | Record<string, unknown> | unknown[], payload?: unknown): void;
+    // (undocumented)
+    state: 'initializing' | 'reconnecting' | 'connected';
+    // (undocumented)
+    useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection<Channels[C]>;
+}
+
 // @public (undocumented)
 type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
 
@@ -2707,6 +2759,9 @@ type NotificationsCreateRequest = operations['notifications___create']['requestB
 // @public (undocumented)
 export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"];
 
+// @public (undocumented)
+export function nyaize(text: string): string;
+
 // @public (undocumented)
 type Page = components['schemas']['Page'];
 
@@ -2997,10 +3052,8 @@ type SignupResponse = MeDetailed & {
 // @public (undocumented)
 type StatsResponse = operations['stats']['responses']['200']['content']['application/json'];
 
-// Warning: (ae-forgotten-export) The symbol "StreamEvents" needs to be exported by the entry point index.d.ts
-//
 // @public (undocumented)
-export class Stream extends EventEmitter<StreamEvents> {
+export class Stream extends EventEmitter<StreamEvents> implements IStream {
     constructor(origin: string, user: {
         token: string;
     } | null, options?: {
@@ -3008,20 +3061,14 @@ export class Stream extends EventEmitter<StreamEvents> {
     });
     // (undocumented)
     close(): void;
-    // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
     disconnectToChannel(connection: NonSharedConnection): void;
     // (undocumented)
     heartbeat(): void;
     // (undocumented)
     ping(): void;
-    // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
     removeSharedConnection(connection: SharedConnection): void;
-    // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
     removeSharedConnectionPool(pool: Pool): void;
     // (undocumented)
@@ -3036,6 +3083,14 @@ export class Stream extends EventEmitter<StreamEvents> {
     useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnection<Channels[C]>;
 }
 
+// Warning: (ae-forgotten-export) The symbol "BroadcastEvents" needs to be exported by the entry point index.d.ts
+//
+// @public (undocumented)
+export type StreamEvents = {
+    _connected_: void;
+    _disconnected_: void;
+} & BroadcastEvents;
+
 // Warning: (ae-forgotten-export) The symbol "SwitchCase" needs to be exported by the entry point index.d.ts
 // Warning: (ae-forgotten-export) The symbol "IsCaseMatched" needs to be exported by the entry point index.d.ts
 // Warning: (ae-forgotten-export) The symbol "GetCaseResult" needs to be exported by the entry point index.d.ts
diff --git a/packages/misskey-js/src/index.ts b/packages/misskey-js/src/index.ts
index ace9738e6a19ffd6d2e6a253dbc54455ee09df54..e4c9364aa1c2a0a184e45061020d75ce14fb6692 100644
--- a/packages/misskey-js/src/index.ts
+++ b/packages/misskey-js/src/index.ts
@@ -1,15 +1,6 @@
-import { type Endpoints } from './api.types.js';
 import Stream, { Connection } from './streaming.js';
-import { type Channels } from './streaming.types.js';
-import { type Acct } from './acct.js';
 import * as consts from './consts.js';
 
-export type {
-	Endpoints,
-	Channels,
-	Acct,
-};
-
 export {
 	Stream,
 	Connection as ChannelConnection,
@@ -31,4 +22,21 @@ import * as api from './api.js';
 import * as entities from './entities.js';
 import * as acct from './acct.js';
 import * as note from './note.js';
-export { api, entities, acct, note };
+import { nyaize } from './nyaize.js';
+export { api, entities, acct, note, nyaize };
+
+//#region standalone types
+import type { Endpoints } from './api.types.js';
+import type { StreamEvents, IStream, IChannelConnection } from './streaming.js';
+import type { Channels } from './streaming.types.js';
+import type { Acct } from './acct.js';
+
+export type {
+	Endpoints,
+	Channels,
+	Acct,
+	StreamEvents,
+	IStream,
+	IChannelConnection,
+};
+//#endregion
diff --git a/packages/frontend/src/scripts/nyaize.ts b/packages/misskey-js/src/nyaize.ts
similarity index 82%
rename from packages/frontend/src/scripts/nyaize.ts
rename to packages/misskey-js/src/nyaize.ts
index abc8ada4617182416f0170eb9eb07edc64924711..729fea8fc37f92e7bb739c82959af37935f61584 100644
--- a/packages/frontend/src/scripts/nyaize.ts
+++ b/packages/misskey-js/src/nyaize.ts
@@ -19,9 +19,9 @@ export function nyaize(text: string): string {
 		.replace(enRegex2, x => x === 'ING' ? 'YAN' : 'yan')
 		.replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan')
 		// ko-KR
-		.replace(koRegex1, match => String.fromCharCode(
-			match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
-		))
+		.replace(koRegex1, match => !isNaN(match.charCodeAt(0)) ? String.fromCharCode(
+			match.charCodeAt(0) + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
+		) : match)
 		.replace(koRegex2, '다냥')
 		.replace(koRegex3, '냥');
 }
diff --git a/packages/misskey-js/src/streaming.ts b/packages/misskey-js/src/streaming.ts
index d1d131cfc134d95aa34416dff09817bd989d8ed9..ffb46c77f6c179002023228d804f5a3d78247291 100644
--- a/packages/misskey-js/src/streaming.ts
+++ b/packages/misskey-js/src/streaming.ts
@@ -17,16 +17,32 @@ export function urlQuery(obj: Record<string, string | number | boolean | undefin
 
 type AnyOf<T extends Record<PropertyKey, unknown>> = T[keyof T];
 
-type StreamEvents = {
+export type StreamEvents = {
 	_connected_: void;
 	_disconnected_: void;
 } & BroadcastEvents;
 
+export interface IStream extends EventEmitter<StreamEvents> {
+	state: 'initializing' | 'reconnecting' | 'connected';
+
+	useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection<Channels[C]>;
+	removeSharedConnection(connection: SharedConnection): void;
+	removeSharedConnectionPool(pool: Pool): void;
+	disconnectToChannel(connection: NonSharedConnection): void;
+	send(typeOrPayload: string): void;
+	send(typeOrPayload: string, payload: unknown): void;
+	send(typeOrPayload: Record<string, unknown> | unknown[]): void;
+	send(typeOrPayload: string | Record<string, unknown> | unknown[], payload?: unknown): void;
+	ping(): void;
+	heartbeat(): void;
+	close(): void;
+}
+
 /**
  * Misskey stream connection
  */
 // eslint-disable-next-line import/no-default-export
-export default class Stream extends EventEmitter<StreamEvents> {
+export default class Stream extends EventEmitter<StreamEvents> implements IStream {
 	private stream: _ReconnectingWebsocket.default;
 	public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing';
 	private sharedConnectionPools: Pool[] = [];
@@ -277,7 +293,18 @@ class Pool {
 	}
 }
 
-export abstract class Connection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> {
+export interface IChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> {
+	id: string;
+	name?: string;
+	inCount: number;
+	outCount: number;
+	channel: string;
+
+	send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void;
+	dispose(): void;
+}
+
+export abstract class Connection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> {
 	public channel: string;
 	protected stream: Stream;
 	public abstract id: string;
diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts
index 0db4cc6381813ceb4771b49656961b8d4b505c24..3000160e41fdebad72abd26239fe2a3a52de55fc 100644
--- a/packages/sw/src/scripts/lang.ts
+++ b/packages/sw/src/scripts/lang.ts
@@ -7,7 +7,7 @@
  * Language manager for SW
  */
 import { get, set } from 'idb-keyval';
-import { I18n } from '../../../frontend/src/scripts/i18n.js';
+import { I18n } from '@@/js/i18n.js';
 import type { Locale } from '../../../../locales/index.js';
 
 class SwLang {
diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts
index 2d39d23ec744ee8253c6d2a656ba91786060cd4c..bf980b83a4878e1b6cf31876b34f962250ea2b4d 100644
--- a/packages/sw/src/sw.ts
+++ b/packages/sw/src/sw.ts
@@ -6,7 +6,7 @@
 import { get } from 'idb-keyval';
 import * as Misskey from 'misskey-js';
 import type { PushNotificationDataMap } from '@/types.js';
-import type { I18n } from '../../frontend/src/scripts/i18n.js';
+import type { I18n } from '@@/js/i18n.js';
 import type { Locale } from '../../../locales/index.js';
 import { createEmptyNotification, createNotification } from '@/scripts/create-notification.js';
 import { swLang } from '@/scripts/lang.js';
diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json
index f3f354301387748545dc3d119888ae5f378a8e2b..2712475a3744393427fe689bcc02219ced46ade0 100644
--- a/packages/sw/tsconfig.json
+++ b/packages/sw/tsconfig.json
@@ -21,7 +21,8 @@
 		"isolatedModules": true,
 		"baseUrl": ".",
 		"paths": {
-			"@/*": ["./src/*"]
+			"@/*": ["./src/*"],
+			"@@/*": ["../frontend-shared/*"]
 		},
 		"typeRoots": [
 			"./node_modules/@types",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 71ce6f6c630cfbf93271f4eb302c32d9388d55ea..60842367fbfa0147a1cd09fee6d9f597e22da258 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -775,6 +775,9 @@ importers:
       eventemitter3:
         specifier: 5.0.1
         version: 5.0.1
+      frontend-shared:
+        specifier: workspace:*
+        version: link:../frontend-shared
       idb-keyval:
         specifier: 6.2.1
         version: 6.2.1
@@ -1044,6 +1047,245 @@ importers:
         specifier: 2.0.29
         version: 2.0.29(typescript@5.5.4)
 
+  packages/frontend-embed:
+    dependencies:
+      '@discordapp/twemoji':
+        specifier: 15.0.3
+        version: 15.0.3
+      '@github/webauthn-json':
+        specifier: 2.1.1
+        version: 2.1.1
+      '@rollup/plugin-json':
+        specifier: 6.1.0
+        version: 6.1.0(rollup@4.19.1)
+      '@rollup/plugin-replace':
+        specifier: 5.0.7
+        version: 5.0.7(rollup@4.19.1)
+      '@rollup/pluginutils':
+        specifier: 5.1.0
+        version: 5.1.0(rollup@4.19.1)
+      '@tabler/icons-webfont':
+        specifier: 3.3.0
+        version: 3.3.0
+      '@twemoji/parser':
+        specifier: 15.1.1
+        version: 15.1.1
+      '@vitejs/plugin-vue':
+        specifier: 5.1.0
+        version: 5.1.0(vite@5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3))(vue@3.4.37(typescript@5.5.4))
+      '@vue/compiler-sfc':
+        specifier: 3.4.37
+        version: 3.4.37
+      astring:
+        specifier: 1.8.6
+        version: 1.8.6
+      buraha:
+        specifier: 0.0.1
+        version: 0.0.1
+      compare-versions:
+        specifier: 6.1.1
+        version: 6.1.1
+      date-fns:
+        specifier: 2.30.0
+        version: 2.30.0
+      escape-regexp:
+        specifier: 0.0.1
+        version: 0.0.1
+      estree-walker:
+        specifier: 3.0.3
+        version: 3.0.3
+      eventemitter3:
+        specifier: 5.0.1
+        version: 5.0.1
+      frontend-shared:
+        specifier: workspace:*
+        version: link:../frontend-shared
+      idb-keyval:
+        specifier: 6.2.1
+        version: 6.2.1
+      is-file-animated:
+        specifier: 1.0.2
+        version: 1.0.2
+      json5:
+        specifier: 2.2.3
+        version: 2.2.3
+      mfm-js:
+        specifier: 0.24.0
+        version: 0.24.0
+      misskey-js:
+        specifier: workspace:*
+        version: link:../misskey-js
+      punycode:
+        specifier: 2.3.1
+        version: 2.3.1
+      rollup:
+        specifier: 4.19.1
+        version: 4.19.1
+      sanitize-html:
+        specifier: 2.13.0
+        version: 2.13.0
+      sass:
+        specifier: 1.77.8
+        version: 1.77.8
+      shiki:
+        specifier: 1.12.0
+        version: 1.12.0
+      strict-event-emitter-types:
+        specifier: 2.0.0
+        version: 2.0.0
+      throttle-debounce:
+        specifier: 5.0.2
+        version: 5.0.2
+      tinycolor2:
+        specifier: 1.6.0
+        version: 1.6.0
+      tsc-alias:
+        specifier: 1.8.10
+        version: 1.8.10
+      tsconfig-paths:
+        specifier: 4.2.0
+        version: 4.2.0
+      typescript:
+        specifier: 5.5.4
+        version: 5.5.4
+      uuid:
+        specifier: 10.0.0
+        version: 10.0.0
+      vite:
+        specifier: 5.3.5
+        version: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3)
+      vue:
+        specifier: 3.4.37
+        version: 3.4.37(typescript@5.5.4)
+    devDependencies:
+      '@misskey-dev/summaly':
+        specifier: 5.1.0
+        version: 5.1.0
+      '@testing-library/vue':
+        specifier: 8.1.0
+        version: 8.1.0(@vue/compiler-sfc@3.4.37)(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))
+      '@types/escape-regexp':
+        specifier: 0.0.3
+        version: 0.0.3
+      '@types/estree':
+        specifier: 1.0.5
+        version: 1.0.5
+      '@types/micromatch':
+        specifier: 4.0.9
+        version: 4.0.9
+      '@types/node':
+        specifier: 20.14.12
+        version: 20.14.12
+      '@types/punycode':
+        specifier: 2.1.4
+        version: 2.1.4
+      '@types/sanitize-html':
+        specifier: 2.11.0
+        version: 2.11.0
+      '@types/throttle-debounce':
+        specifier: 5.0.2
+        version: 5.0.2
+      '@types/tinycolor2':
+        specifier: 1.4.6
+        version: 1.4.6
+      '@types/uuid':
+        specifier: 10.0.0
+        version: 10.0.0
+      '@types/ws':
+        specifier: 8.5.11
+        version: 8.5.11
+      '@typescript-eslint/eslint-plugin':
+        specifier: 7.17.0
+        version: 7.17.0(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0)(typescript@5.5.4)
+      '@typescript-eslint/parser':
+        specifier: 7.17.0
+        version: 7.17.0(eslint@9.8.0)(typescript@5.5.4)
+      '@vitest/coverage-v8':
+        specifier: 1.6.0
+        version: 1.6.0(vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3))
+      '@vue/runtime-core':
+        specifier: 3.4.37
+        version: 3.4.37
+      acorn:
+        specifier: 8.12.1
+        version: 8.12.1
+      cross-env:
+        specifier: 7.0.3
+        version: 7.0.3
+      eslint-plugin-import:
+        specifier: 2.29.1
+        version: 2.29.1(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0)
+      eslint-plugin-vue:
+        specifier: 9.27.0
+        version: 9.27.0(eslint@9.8.0)
+      fast-glob:
+        specifier: 3.3.2
+        version: 3.3.2
+      happy-dom:
+        specifier: 10.0.3
+        version: 10.0.3
+      intersection-observer:
+        specifier: 0.12.2
+        version: 0.12.2
+      micromatch:
+        specifier: 4.0.7
+        version: 4.0.7
+      msw:
+        specifier: 2.3.4
+        version: 2.3.4(typescript@5.5.4)
+      nodemon:
+        specifier: 3.1.4
+        version: 3.1.4
+      prettier:
+        specifier: 3.3.3
+        version: 3.3.3
+      start-server-and-test:
+        specifier: 2.0.4
+        version: 2.0.4
+      vite-plugin-turbosnap:
+        specifier: 1.0.3
+        version: 1.0.3
+      vue-component-type-helpers:
+        specifier: 2.0.29
+        version: 2.0.29
+      vue-eslint-parser:
+        specifier: 9.4.3
+        version: 9.4.3(eslint@9.8.0)
+      vue-tsc:
+        specifier: 2.0.29
+        version: 2.0.29(typescript@5.5.4)
+
+  packages/frontend-shared:
+    dependencies:
+      misskey-js:
+        specifier: workspace:*
+        version: link:../misskey-js
+      vue:
+        specifier: 3.4.37
+        version: 3.4.37(typescript@5.5.4)
+    devDependencies:
+      '@types/node':
+        specifier: 20.14.12
+        version: 20.14.12
+      '@typescript-eslint/eslint-plugin':
+        specifier: 7.17.0
+        version: 7.17.0(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0)(typescript@5.5.4)
+      '@typescript-eslint/parser':
+        specifier: 7.17.0
+        version: 7.17.0(eslint@9.8.0)(typescript@5.5.4)
+      esbuild:
+        specifier: 0.23.0
+        version: 0.23.0
+      eslint-plugin-vue:
+        specifier: 9.27.0
+        version: 9.27.0(eslint@9.8.0)
+      typescript:
+        specifier: 5.5.4
+        version: 5.5.4
+      vue-eslint-parser:
+        specifier: 9.4.3
+        version: 9.4.3(eslint@9.8.0)
+
   packages/misskey-bubble-game:
     dependencies:
       eventemitter3:
@@ -5380,15 +5622,9 @@ packages:
   '@vue/compiler-core@3.4.31':
     resolution: {integrity: sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==}
 
-  '@vue/compiler-core@3.4.34':
-    resolution: {integrity: sha512-Z0izUf32+wAnQewjHu+pQf1yw00EGOmevl1kE+ljjjMe7oEfpQ+BI3/JNK7yMB4IrUsqLDmPecUrpj3mCP+yJQ==}
-
   '@vue/compiler-core@3.4.37':
     resolution: {integrity: sha512-ZDDT/KiLKuCRXyzWecNzC5vTcubGz4LECAtfGPENpo0nrmqJHwuWtRLxk/Sb9RAKtR9iFflFycbkjkY+W/PZUQ==}
 
-  '@vue/compiler-dom@3.4.34':
-    resolution: {integrity: sha512-3PUOTS1h5cskdOJMExCu2TInXuM0j60DRPpSCJDqOCupCfUZCJoyQmKtRmA8EgDNZ5kcEE7vketamRZfrEuVDw==}
-
   '@vue/compiler-dom@3.4.37':
     resolution: {integrity: sha512-rIiSmL3YrntvgYV84rekAtU/xfogMUJIclUMeIKEtVBFngOL3IeZHhsH3UaFEgB5iFGpj6IW+8YuM/2Up+vVag==}
 
@@ -5437,9 +5673,6 @@ packages:
   '@vue/shared@3.4.31':
     resolution: {integrity: sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA==}
 
-  '@vue/shared@3.4.34':
-    resolution: {integrity: sha512-x5LmiRLpRsd9KTjAB8MPKf0CDPMcuItjP0gbNqFCIgL1I8iYp4zglhj9w9FPCdIbHG2M91RVeIbArFfFTz9I3A==}
-
   '@vue/shared@3.4.37':
     resolution: {integrity: sha512-nIh8P2fc3DflG8+5Uw8PT/1i17ccFn0xxN/5oE9RfV5SVnd7G0XEFRwakrnNFE/jlS95fpGXDVG5zDETS26nmg==}
 
@@ -6985,10 +7218,6 @@ packages:
     engines: {node: '>=4'}
     hasBin: true
 
-  esquery@1.4.2:
-    resolution: {integrity: sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==}
-    engines: {node: '>=0.10'}
-
   esquery@1.6.0:
     resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
     engines: {node: '>=0.10'}
@@ -7804,6 +8033,7 @@ packages:
 
   inflight@1.0.6:
     resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
 
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -9198,10 +9428,6 @@ packages:
     resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
     engines: {node: '>=10'}
 
-  normalize-url@8.0.0:
-    resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==}
-    engines: {node: '>=14.16'}
-
   normalize-url@8.0.1:
     resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==}
     engines: {node: '>=14.16'}
@@ -9814,10 +10040,6 @@ packages:
     peerDependencies:
       postcss: ^8.4.31
 
-  postcss-selector-parser@6.0.15:
-    resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==}
-    engines: {node: '>=4'}
-
   postcss-selector-parser@6.0.16:
     resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==}
     engines: {node: '>=4'}
@@ -9837,10 +10059,6 @@ packages:
   postcss-value-parser@4.2.0:
     resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
 
-  postcss@8.4.38:
-    resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
-    engines: {node: ^10 || ^12 || >=14}
-
   postcss@8.4.40:
     resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
     engines: {node: ^10 || ^12 || >=14}
@@ -11115,7 +11333,6 @@ packages:
 
   ts-case-convert@2.0.2:
     resolution: {integrity: sha512-vdKfx1VAdpvEBOBv5OpVu5ZFqRg9HdTI4sYt6qqMeICBeNyXvitrarCnFWNDAki51IKwCyx+ZssY46Q9jH5otA==}
-    bundledDependencies: []
 
   ts-dedent@2.2.0:
     resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
@@ -11596,6 +11813,9 @@ packages:
   vue-component-type-helpers@2.0.29:
     resolution: {integrity: sha512-58i+ZhUAUpwQ+9h5Hck0D+jr1qbYl4voRt5KffBx8qzELViQ4XdT/Tuo+mzq8u63teAG8K0lLaOiL5ofqW38rg==}
 
+  vue-component-type-helpers@2.1.6:
+    resolution: {integrity: sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==}
+
   vue-demi@0.14.7:
     resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
     engines: {node: '>=12'}
@@ -11909,8 +12129,8 @@ snapshots:
 
   '@ampproject/remapping@2.2.1':
     dependencies:
-      '@jridgewell/gen-mapping': 0.3.2
-      '@jridgewell/trace-mapping': 0.3.18
+      '@jridgewell/gen-mapping': 0.3.5
+      '@jridgewell/trace-mapping': 0.3.25
 
   '@apidevtools/openapi-schemas@2.1.0': {}
 
@@ -12435,7 +12655,7 @@ snapshots:
   '@babel/code-frame@7.24.7':
     dependencies:
       '@babel/highlight': 7.24.7
-      picocolors: 1.0.0
+      picocolors: 1.0.1
 
   '@babel/compat-data@7.23.5': {}
 
@@ -12454,7 +12674,7 @@ snapshots:
       '@babel/traverse': 7.23.5
       '@babel/types': 7.24.7
       convert-source-map: 2.0.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -12474,7 +12694,7 @@ snapshots:
       '@babel/traverse': 7.24.7
       '@babel/types': 7.24.7
       convert-source-map: 2.0.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -12549,7 +12769,7 @@ snapshots:
       '@babel/core': 7.24.7
       '@babel/helper-compilation-targets': 7.24.7
       '@babel/helper-plugin-utils': 7.24.7
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -12710,7 +12930,7 @@ snapshots:
       '@babel/helper-validator-identifier': 7.24.7
       chalk: 2.4.2
       js-tokens: 4.0.0
-      picocolors: 1.0.0
+      picocolors: 1.0.1
 
   '@babel/parser@7.24.7':
     dependencies:
@@ -13390,7 +13610,7 @@ snapshots:
 
   '@babel/template@7.22.15':
     dependencies:
-      '@babel/code-frame': 7.23.5
+      '@babel/code-frame': 7.24.7
       '@babel/parser': 7.24.7
       '@babel/types': 7.24.7
 
@@ -13408,7 +13628,7 @@ snapshots:
 
   '@babel/traverse@7.23.5':
     dependencies:
-      '@babel/code-frame': 7.23.5
+      '@babel/code-frame': 7.24.7
       '@babel/generator': 7.23.5
       '@babel/helper-environment-visitor': 7.22.20
       '@babel/helper-function-name': 7.23.0
@@ -13416,7 +13636,7 @@ snapshots:
       '@babel/helper-split-export-declaration': 7.22.6
       '@babel/parser': 7.24.7
       '@babel/types': 7.24.7
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -13431,7 +13651,7 @@ snapshots:
       '@babel/helper-split-export-declaration': 7.24.7
       '@babel/parser': 7.24.7
       '@babel/types': 7.24.7
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -13891,7 +14111,7 @@ snapshots:
   '@eslint/config-array@0.17.1':
     dependencies:
       '@eslint/object-schema': 2.1.4
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
@@ -13899,7 +14119,7 @@ snapshots:
   '@eslint/eslintrc@3.1.0':
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       espree: 10.1.0
       globals: 14.0.0
       ignore: 5.3.1
@@ -14275,7 +14495,7 @@ snapshots:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@jridgewell/trace-mapping': 0.3.18
+      '@jridgewell/trace-mapping': 0.3.25
       '@types/node': 20.14.12
       chalk: 4.1.2
       collect-v8-coverage: 1.0.1
@@ -14303,7 +14523,7 @@ snapshots:
 
   '@jest/source-map@29.6.3':
     dependencies:
-      '@jridgewell/trace-mapping': 0.3.18
+      '@jridgewell/trace-mapping': 0.3.25
       callsites: 3.1.0
       graceful-fs: 4.2.11
 
@@ -14325,7 +14545,7 @@ snapshots:
     dependencies:
       '@babel/core': 7.24.7
       '@jest/types': 29.6.3
-      '@jridgewell/trace-mapping': 0.3.18
+      '@jridgewell/trace-mapping': 0.3.25
       babel-plugin-istanbul: 6.1.1
       chalk: 4.1.2
       convert-source-map: 2.0.0
@@ -16219,12 +16439,12 @@ snapshots:
       '@storybook/global': 5.0.0
       '@storybook/preview-api': 8.1.11
       '@storybook/types': 8.1.11
-      '@vue/compiler-core': 3.4.34
+      '@vue/compiler-core': 3.4.37
       lodash: 4.17.21
       ts-dedent: 2.2.0
       type-fest: 2.19.0
       vue: 3.4.37(typescript@5.5.4)
-      vue-component-type-helpers: 2.0.29
+      vue-component-type-helpers: 2.1.6
     transitivePeerDependencies:
       - encoding
       - prettier
@@ -16243,7 +16463,7 @@ snapshots:
       ts-dedent: 2.2.0
       type-fest: 2.19.0
       vue: 3.4.37(typescript@5.5.4)
-      vue-component-type-helpers: 2.0.29
+      vue-component-type-helpers: 2.1.6
 
   '@swc/cli@0.3.12(@swc/core@1.6.6)(chokidar@3.5.3)':
     dependencies:
@@ -16527,7 +16747,7 @@ snapshots:
 
   '@testing-library/dom@9.3.4':
     dependencies:
-      '@babel/code-frame': 7.23.5
+      '@babel/code-frame': 7.24.7
       '@babel/runtime': 7.23.4
       '@types/aria-query': 5.0.1
       aria-query: 5.1.3
@@ -17065,7 +17285,7 @@ snapshots:
       '@typescript-eslint/types': 7.17.0
       '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.5.4)
       '@typescript-eslint/visitor-keys': 7.17.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       eslint: 9.8.0
     optionalDependencies:
       typescript: 5.5.4
@@ -17091,7 +17311,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
       '@typescript-eslint/utils': 6.11.0(eslint@9.8.0)(typescript@5.3.3)
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       eslint: 9.8.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
     optionalDependencies:
@@ -17103,7 +17323,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/typescript-estree': 7.1.0(typescript@5.3.3)
       '@typescript-eslint/utils': 7.1.0(eslint@9.8.0)(typescript@5.3.3)
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
       eslint: 9.8.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
     optionalDependencies:
@@ -17115,7 +17335,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.5.4)
       '@typescript-eslint/utils': 7.17.0(eslint@9.8.0)(typescript@5.5.4)
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       eslint: 9.8.0
       ts-api-utils: 1.3.0(typescript@5.5.4)
     optionalDependencies:
@@ -17133,10 +17353,10 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 6.11.0
       '@typescript-eslint/visitor-keys': 6.11.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       globby: 11.1.0
       is-glob: 4.0.3
-      semver: 7.5.4
+      semver: 7.6.0
       ts-api-utils: 1.0.1(typescript@5.3.3)
     optionalDependencies:
       typescript: 5.3.3
@@ -17147,7 +17367,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 7.1.0
       '@typescript-eslint/visitor-keys': 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
       globby: 11.1.0
       is-glob: 4.0.3
       minimatch: 9.0.3
@@ -17162,7 +17382,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/types': 7.17.0
       '@typescript-eslint/visitor-keys': 7.17.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       globby: 11.1.0
       is-glob: 4.0.3
       minimatch: 9.0.4
@@ -17182,7 +17402,7 @@ snapshots:
       '@typescript-eslint/types': 6.11.0
       '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3)
       eslint: 9.8.0
-      semver: 7.5.4
+      semver: 7.6.0
     transitivePeerDependencies:
       - supports-color
       - typescript
@@ -17238,14 +17458,14 @@ snapshots:
     dependencies:
       '@ampproject/remapping': 2.2.1
       '@bcoe/v8-coverage': 0.2.3
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
       istanbul-lib-coverage: 3.2.2
       istanbul-lib-report: 3.0.1
       istanbul-lib-source-maps: 5.0.4
       istanbul-reports: 3.1.6
       magic-string: 0.30.10
       magicast: 0.3.4
-      picocolors: 1.0.0
+      picocolors: 1.0.1
       std-env: 3.7.0
       strip-literal: 2.1.0
       test-exclude: 6.0.0
@@ -17253,6 +17473,25 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3))':
+    dependencies:
+      '@ampproject/remapping': 2.2.1
+      '@bcoe/v8-coverage': 0.2.3
+      debug: 4.3.5(supports-color@5.5.0)
+      istanbul-lib-coverage: 3.2.2
+      istanbul-lib-report: 3.0.1
+      istanbul-lib-source-maps: 5.0.4
+      istanbul-reports: 3.1.6
+      magic-string: 0.30.10
+      magicast: 0.3.4
+      picocolors: 1.0.1
+      std-env: 3.7.0
+      strip-literal: 2.1.0
+      test-exclude: 6.0.0
+      vitest: 1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3)
+    transitivePeerDependencies:
+      - supports-color
+
   '@vitest/expect@1.6.0':
     dependencies:
       '@vitest/spy': 1.6.0
@@ -17315,14 +17554,6 @@ snapshots:
       estree-walker: 2.0.2
       source-map-js: 1.2.0
 
-  '@vue/compiler-core@3.4.34':
-    dependencies:
-      '@babel/parser': 7.24.7
-      '@vue/shared': 3.4.34
-      entities: 4.5.0
-      estree-walker: 2.0.2
-      source-map-js: 1.2.0
-
   '@vue/compiler-core@3.4.37':
     dependencies:
       '@babel/parser': 7.24.7
@@ -17331,11 +17562,6 @@ snapshots:
       estree-walker: 2.0.2
       source-map-js: 1.2.0
 
-  '@vue/compiler-dom@3.4.34':
-    dependencies:
-      '@vue/compiler-core': 3.4.34
-      '@vue/shared': 3.4.34
-
   '@vue/compiler-dom@3.4.37':
     dependencies:
       '@vue/compiler-core': 3.4.37
@@ -17368,8 +17594,8 @@ snapshots:
   '@vue/language-core@2.0.16(typescript@5.5.4)':
     dependencies:
       '@volar/language-core': 2.2.0
-      '@vue/compiler-dom': 3.4.34
-      '@vue/shared': 3.4.34
+      '@vue/compiler-dom': 3.4.37
+      '@vue/shared': 3.4.37
       computeds: 0.0.1
       minimatch: 9.0.4
       path-browserify: 1.0.1
@@ -17380,9 +17606,9 @@ snapshots:
   '@vue/language-core@2.0.29(typescript@5.5.4)':
     dependencies:
       '@volar/language-core': 2.4.0-alpha.18
-      '@vue/compiler-dom': 3.4.34
+      '@vue/compiler-dom': 3.4.37
       '@vue/compiler-vue2': 2.7.16
-      '@vue/shared': 3.4.34
+      '@vue/shared': 3.4.37
       computeds: 0.0.1
       minimatch: 9.0.4
       muggle-string: 0.4.1
@@ -17414,8 +17640,6 @@ snapshots:
 
   '@vue/shared@3.4.31': {}
 
-  '@vue/shared@3.4.34': {}
-
   '@vue/shared@3.4.37': {}
 
   '@vue/test-utils@2.4.1(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))':
@@ -17495,13 +17719,13 @@ snapshots:
 
   agent-base@6.0.2:
     dependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
   agent-base@7.1.0:
     dependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -17759,7 +17983,7 @@ snapshots:
     dependencies:
       '@fastify/error': 3.4.0
       archy: 1.0.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       fastq: 1.17.1
     transitivePeerDependencies:
       - supports-color
@@ -18076,7 +18300,7 @@ snapshots:
       http-cache-semantics: 4.1.1
       keyv: 4.5.4
       mimic-response: 4.0.0
-      normalize-url: 8.0.0
+      normalize-url: 8.0.1
       responselike: 3.0.0
 
   cacheable-request@12.0.1:
@@ -18688,6 +18912,12 @@ snapshots:
     optionalDependencies:
       supports-color: 5.5.0
 
+  debug@4.3.5(supports-color@5.5.0):
+    dependencies:
+      ms: 2.1.2
+    optionalDependencies:
+      supports-color: 5.5.0
+
   debug@4.3.5(supports-color@8.1.1):
     dependencies:
       ms: 2.1.2
@@ -18807,7 +19037,7 @@ snapshots:
   detect-port@1.5.1:
     dependencies:
       address: 1.2.2
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -19020,7 +19250,7 @@ snapshots:
 
   esbuild-register@3.5.0(esbuild@0.19.11):
     dependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       esbuild: 0.19.11
     transitivePeerDependencies:
       - supports-color
@@ -19250,7 +19480,7 @@ snapshots:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       escape-string-regexp: 4.0.0
       eslint-scope: 8.0.2
       eslint-visitor-keys: 4.0.0
@@ -19290,10 +19520,6 @@ snapshots:
 
   esprima@4.0.1: {}
 
-  esquery@1.4.2:
-    dependencies:
-      estraverse: 5.3.0
-
   esquery@1.6.0:
     dependencies:
       estraverse: 5.3.0
@@ -19707,7 +19933,7 @@ snapshots:
 
   follow-redirects@1.15.2(debug@4.3.5):
     optionalDependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
 
   for-each@0.3.3:
     dependencies:
@@ -20167,7 +20393,7 @@ snapshots:
   http-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -20206,28 +20432,28 @@ snapshots:
   https-proxy-agent@5.0.1:
     dependencies:
       agent-base: 6.0.2
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
   https-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
   https-proxy-agent@7.0.4:
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
   https-proxy-agent@7.0.5:
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -20575,7 +20801,7 @@ snapshots:
 
   istanbul-lib-source-maps@4.0.1:
     dependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       istanbul-lib-coverage: 3.2.2
       source-map: 0.6.1
     transitivePeerDependencies:
@@ -20584,7 +20810,7 @@ snapshots:
   istanbul-lib-source-maps@5.0.4:
     dependencies:
       '@jridgewell/trace-mapping': 0.3.25
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       istanbul-lib-coverage: 3.2.2
     transitivePeerDependencies:
       - supports-color
@@ -20884,7 +21110,7 @@ snapshots:
       jest-util: 29.7.0
       natural-compare: 1.4.0
       pretty-format: 29.7.0
-      semver: 7.5.4
+      semver: 7.6.0
     transitivePeerDependencies:
       - supports-color
 
@@ -21008,6 +21234,35 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  jsdom@24.1.1:
+    dependencies:
+      cssstyle: 4.0.1
+      data-urls: 5.0.0
+      decimal.js: 10.4.3
+      form-data: 4.0.0
+      html-encoding-sniffer: 4.0.0
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.5
+      is-potential-custom-element-name: 1.0.1
+      nwsapi: 2.2.12
+      parse5: 7.1.2
+      rrweb-cssom: 0.7.1
+      saxes: 6.0.0
+      symbol-tree: 3.2.4
+      tough-cookie: 4.1.4
+      w3c-xmlserializer: 5.0.0
+      webidl-conversions: 7.0.0
+      whatwg-encoding: 3.1.1
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.0.0
+      ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
+      xml-name-validator: 5.0.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+    optional: true
+
   jsdom@24.1.1(bufferutil@4.0.7)(utf-8-validate@6.0.3):
     dependencies:
       cssstyle: 4.0.1
@@ -21693,7 +21948,7 @@ snapshots:
   micromark@4.0.0:
     dependencies:
       '@types/debug': 4.1.12
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       decode-named-character-reference: 1.0.2
       devlop: 1.1.0
       micromark-core-commonmark: 2.0.0
@@ -22066,7 +22321,7 @@ snapshots:
   nodemon@3.1.4:
     dependencies:
       chokidar: 3.5.3
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
       ignore-by-default: 1.0.1
       minimatch: 3.1.2
       pstree.remy: 1.1.8
@@ -22113,8 +22368,6 @@ snapshots:
 
   normalize-url@6.1.0: {}
 
-  normalize-url@8.0.0: {}
-
   normalize-url@8.0.1: {}
 
   npm-run-path@2.0.2:
@@ -22571,7 +22824,7 @@ snapshots:
   postcss-calc@9.0.1(postcss@8.4.40):
     dependencies:
       postcss: 8.4.40
-      postcss-selector-parser: 6.0.15
+      postcss-selector-parser: 6.0.16
       postcss-value-parser: 4.2.0
 
   postcss-colormin@6.1.0(postcss@8.4.40):
@@ -22704,11 +22957,6 @@ snapshots:
       postcss: 8.4.40
       postcss-value-parser: 4.2.0
 
-  postcss-selector-parser@6.0.15:
-    dependencies:
-      cssesc: 3.0.0
-      util-deprecate: 1.0.2
-
   postcss-selector-parser@6.0.16:
     dependencies:
       cssesc: 3.0.0
@@ -22727,12 +22975,6 @@ snapshots:
 
   postcss-value-parser@4.2.0: {}
 
-  postcss@8.4.38:
-    dependencies:
-      nanoid: 3.3.7
-      picocolors: 1.0.0
-      source-map-js: 1.2.0
-
   postcss@8.4.40:
     dependencies:
       nanoid: 3.3.7
@@ -23263,7 +23505,7 @@ snapshots:
 
   require-in-the-middle@7.3.0:
     dependencies:
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       module-details-from-path: 1.0.3
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -23399,7 +23641,7 @@ snapshots:
       htmlparser2: 8.0.1
       is-plain-object: 5.0.0
       parse-srcset: 1.0.2
-      postcss: 8.4.38
+      postcss: 8.4.40
 
   sass@1.77.8:
     dependencies:
@@ -23544,7 +23786,7 @@ snapshots:
     dependencies:
       '@hapi/hoek': 11.0.4
       '@hapi/wreck': 18.0.1
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       joi: 17.11.0
     transitivePeerDependencies:
       - supports-color
@@ -23555,7 +23797,7 @@ snapshots:
 
   simple-update-notifier@2.0.0:
     dependencies:
-      semver: 7.5.4
+      semver: 7.6.0
 
   sinon@16.1.3:
     dependencies:
@@ -23644,7 +23886,7 @@ snapshots:
   socks-proxy-agent@8.0.2:
     dependencies:
       agent-base: 7.1.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       socks: 2.7.1
     transitivePeerDependencies:
       - supports-color
@@ -23739,7 +23981,7 @@ snapshots:
       arg: 5.0.2
       bluebird: 3.7.2
       check-more-types: 2.24.0
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       execa: 5.1.1
       lazy-ass: 1.6.0
       ps-tree: 1.2.0
@@ -23968,7 +24210,7 @@ snapshots:
       css-tree: 2.3.1
       css-what: 6.1.0
       csso: 5.0.5
-      picocolors: 1.0.0
+      picocolors: 1.0.1
 
   symbol-tree@3.2.4: {}
 
@@ -24391,13 +24633,13 @@ snapshots:
     dependencies:
       browserslist: 4.22.2
       escalade: 3.1.1
-      picocolors: 1.0.0
+      picocolors: 1.0.1
 
   update-browserslist-db@1.0.13(browserslist@4.23.0):
     dependencies:
       browserslist: 4.23.0
       escalade: 3.1.1
-      picocolors: 1.0.0
+      picocolors: 1.0.1
 
   uri-js@4.4.1:
     dependencies:
@@ -24449,7 +24691,7 @@ snapshots:
 
   v8-to-istanbul@9.2.0:
     dependencies:
-      '@jridgewell/trace-mapping': 0.3.18
+      '@jridgewell/trace-mapping': 0.3.25
       '@types/istanbul-lib-coverage': 2.0.4
       convert-source-map: 2.0.0
 
@@ -24480,9 +24722,9 @@ snapshots:
   vite-node@1.6.0(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3):
     dependencies:
       cac: 6.7.14
-      debug: 4.3.5(supports-color@8.1.1)
+      debug: 4.3.5(supports-color@5.5.0)
       pathe: 1.1.2
-      picocolors: 1.0.0
+      picocolors: 1.0.1
       vite: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3)
     transitivePeerDependencies:
       - '@types/node'
@@ -24549,6 +24791,41 @@ snapshots:
       - supports-color
       - terser
 
+  vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3):
+    dependencies:
+      '@vitest/expect': 1.6.0
+      '@vitest/runner': 1.6.0
+      '@vitest/snapshot': 1.6.0
+      '@vitest/spy': 1.6.0
+      '@vitest/utils': 1.6.0
+      acorn-walk: 8.3.2
+      chai: 4.3.10
+      debug: 4.3.4(supports-color@5.5.0)
+      execa: 8.0.1
+      local-pkg: 0.5.0
+      magic-string: 0.30.10
+      pathe: 1.1.2
+      picocolors: 1.0.0
+      std-env: 3.7.0
+      strip-literal: 2.1.0
+      tinybench: 2.6.0
+      tinypool: 0.8.4
+      vite: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3)
+      vite-node: 1.6.0(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3)
+      why-is-node-running: 2.2.2
+    optionalDependencies:
+      '@types/node': 20.14.12
+      happy-dom: 10.0.3
+      jsdom: 24.1.1
+    transitivePeerDependencies:
+      - less
+      - lightningcss
+      - sass
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+
   void-elements@3.1.0: {}
 
   vscode-jsonrpc@8.2.0: {}
@@ -24589,6 +24866,8 @@ snapshots:
 
   vue-component-type-helpers@2.0.29: {}
 
+  vue-component-type-helpers@2.1.6: {}
+
   vue-demi@0.14.7(vue@3.4.37(typescript@5.5.4)):
     dependencies:
       vue: 3.4.37(typescript@5.5.4)
@@ -24597,7 +24876,7 @@ snapshots:
     dependencies:
       '@babel/parser': 7.24.7
       '@babel/types': 7.24.7
-      '@vue/compiler-dom': 3.4.34
+      '@vue/compiler-dom': 3.4.37
       '@vue/compiler-sfc': 3.4.37
       ast-types: 0.16.1
       hash-sum: 2.0.0
@@ -24610,12 +24889,12 @@ snapshots:
 
   vue-eslint-parser@9.4.3(eslint@9.8.0):
     dependencies:
-      debug: 4.3.4(supports-color@5.5.0)
+      debug: 4.3.5(supports-color@5.5.0)
       eslint: 9.8.0
       eslint-scope: 7.2.2
       eslint-visitor-keys: 3.4.3
       espree: 9.6.1
-      esquery: 1.4.2
+      esquery: 1.6.0
       lodash: 4.17.21
       semver: 7.6.0
     transitivePeerDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 193669e7a40212208752349235b15fe5f358aa6e..d222614edaab5ee98b868244a11edaabaacb6d55 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,8 @@
 packages:
  - 'packages/backend'
+ - 'packages/frontend-shared'
  - 'packages/frontend'
+ - 'packages/frontend-embed'
  - 'packages/sw'
  - 'packages/misskey-js'
  - 'packages/misskey-js/generator'
diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs
index 2b275e12d68712d2a96bdf9fa099b6fc1eb93a7a..421d4a6d1bf2b2b82f73b42114959dc4d130eb0d 100644
--- a/scripts/build-assets.mjs
+++ b/scripts/build-assets.mjs
@@ -58,6 +58,7 @@ async function buildBackendScript() {
 
   for (const file of [
     './packages/backend/src/server/web/boot.js',
+    './packages/backend/src/server/web/boot.embed.js',
     './packages/backend/src/server/web/bios.js',
     './packages/backend/src/server/web/cli.js'
   ]) {
@@ -73,6 +74,7 @@ async function buildBackendStyle() {
 
   for (const file of [
     './packages/backend/src/server/web/style.css',
+    './packages/backend/src/server/web/style.embed.css',
     './packages/backend/src/server/web/bios.css',
     './packages/backend/src/server/web/cli.css',
     './packages/backend/src/server/web/error.css'
diff --git a/scripts/clean-all.js b/scripts/clean-all.js
index e9512e2d5a5b6944fab53b550bee3384c2bb78b1..dc391ecfd8507b44dd9cda14be0e73262d06e9ca 100644
--- a/scripts/clean-all.js
+++ b/scripts/clean-all.js
@@ -10,9 +10,15 @@ const fs = require('fs');
 	fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true });
 
+	fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true });
+
 	fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true });
 
+	fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend-embed/node_modules', { recursive: true, force: true });
+
 	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true });
 
diff --git a/scripts/clean.js b/scripts/clean.js
index af66c24a8f14186ed1c5ea0d8923c9c04cc63c64..86c19281ea264a09dc214464b35bc4ebad942729 100644
--- a/scripts/clean.js
+++ b/scripts/clean.js
@@ -7,7 +7,9 @@ const fs = require('fs');
 
 (async () => {
 	fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/misskey-js/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/misskey-reversi/built', { recursive: true, force: true });
diff --git a/scripts/dev.mjs b/scripts/dev.mjs
index bbb2547758b1360e0867c724b67b4cbf381ad899..a4c82d46e1aa8141f92f57cb83add1771a897dee 100644
--- a/scripts/dev.mjs
+++ b/scripts/dev.mjs
@@ -65,12 +65,24 @@ execa('pnpm', ['--filter', 'backend', 'dev'], {
 	stderr: process.stderr,
 });
 
+execa('pnpm', ['--filter', 'frontend-shared', 'watch'], {
+	cwd: _dirname + '/../',
+	stdout: process.stdout,
+	stderr: process.stderr,
+});
+
 execa('pnpm', ['--filter', 'frontend', process.env.MK_DEV_PREFER === 'backend' ? 'watch' : 'dev'], {
 	cwd: _dirname + '/../',
 	stdout: process.stdout,
 	stderr: process.stderr,
 });
 
+execa('pnpm', ['--filter', 'frontend-embed', process.env.MK_DEV_PREFER === 'backend' ? 'watch' : 'dev'], {
+	cwd: _dirname + '/../',
+	stdout: process.stdout,
+	stderr: process.stderr,
+});
+
 execa('pnpm', ['--filter', 'sw', 'watch'], {
 	cwd: _dirname + '/../',
 	stdout: process.stdout,