From ad2412d3cd164580abf54ddd1da52818fb0d4996 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Fri, 21 Jun 2024 13:09:24 +0100
Subject: replaced ti-check

---
 packages/frontend/src/components/MkTutorialDialog.Sensitive.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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

diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
index f155ad7bcb..a2dcede7dd 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		:initialNote="exampleNote"
 		@fileChangeSensitive="doSucceeded"
 	></MkPostForm>
-	<div v-if="onceSucceeded"><b style="color: var(--accent);"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.sensitiveSucceeded }}</div>
+	<div v-if="onceSucceeded"><b style="color: var(--accent);"><i class="ti ti-check"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._howToMakeAttachmentsSensitive.sensitiveSucceeded }}</div>
 	<MkFolder>
 		<template #label>{{ i18n.ts.previewNoteText }}</template>
 		<MkNote :mock="true" :note="exampleNote" :class="$style.exampleRoot"></MkNote>
-- 
cgit v1.2.3-freya


From 385969e9f56a39a1e5547b94901d155e1e811263 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 12 Jul 2024 16:25:44 +0900
Subject: fix(frontend): フォーカスの挙動を修正 (#14158)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): 直前のパターンを記録するように

* fix(frontend): フォーカス/タブ移動に関する挙動を調整 (#226)

Cherry-pick commit e8c030673326871edf3623cf2b8675d68f9e1b13

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>

* focusのデザイン修正

* move scripts

* Modalにfocus trapを追加

* 記録するホットキーはレートリミット式にする

* escキーのハンドリングをMkModalに統一

* fix

* enterで子メニューを開けるように

* lint

* fix focus trap

* improve switch accessibility

* 一部のmodalのフォーカストラップが外れない問題を修正

* fix

* fix

* Revert "記録するホットキーはレートリミット式にする"

This reverts commit 40a7509286a87911ad4cc06d9482e8a2e5d0e7e8.

* Revert "fix(frontend): 直前のパターンを記録するように"

This reverts commit 5372b2594023952cff34aa62253ed4efef15b5dd.

* Revert "Revert "fix(frontend): 直前のパターンを記録するように""

This reverts commit a9bb52e799e110927ad92cd8f26af980819334e1.

* Revert "Revert "記録するホットキーはレートリミット式にする""

This reverts commit bdac34273e0bc5f13604c7e2f9fa6b1321a0df3d.

* 試験的にCypressでのFocustrapを無効化

* fix

* fix focus-trap

* Update Changelog

* :v:

* fix focustrap invocation logic

* スクロールがsticky headerを考慮するように

* :art:

* スタイルの微調整

* :art:

* remove deprecated key aliases

* focusElementが足りなかったので修正

* preview系にfocus時スタイルが足りなかったので修正

* `returnFocusElement` -> `returnFocusTo`

* lint

* Update packages/frontend/src/components/MkModalWindow.vue

* Apply suggestions from code review

Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>

* keydownイベントをまとめる

* use correct pesudo-element selector

* fix

* rename

---------

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                       |   2 +
 .../frontend/src/components/MkAchievements.vue     |   4 +-
 packages/frontend/src/components/MkButton.vue      |   1 -
 .../src/components/MkChannelFollowButton.vue       |  12 +-
 .../frontend/src/components/MkChannelPreview.vue   |  19 +-
 packages/frontend/src/components/MkClipPreview.vue |   8 +
 packages/frontend/src/components/MkContextMenu.vue |   2 +-
 packages/frontend/src/components/MkCwButton.vue    |   4 +-
 packages/frontend/src/components/MkDialog.vue      |  16 +-
 packages/frontend/src/components/MkDrive.file.vue  |  32 +-
 .../frontend/src/components/MkDrive.folder.vue     |   2 +-
 packages/frontend/src/components/MkEmojiPicker.vue |  33 +-
 .../src/components/MkEmojiPickerDialog.vue         |   2 +
 .../frontend/src/components/MkFlashPreview.vue     |   6 +-
 packages/frontend/src/components/MkFolder.vue      |  16 +-
 .../frontend/src/components/MkFollowButton.vue     |  12 +-
 .../src/components/MkGalleryPostPreview.vue        |   4 +-
 .../frontend/src/components/MkImgWithBlurhash.vue  |   4 +-
 packages/frontend/src/components/MkLaunchPad.vue   |   2 +-
 packages/frontend/src/components/MkMediaAudio.vue  |  26 +-
 packages/frontend/src/components/MkMediaList.vue   |  54 +++-
 packages/frontend/src/components/MkMediaVideo.vue  |  10 +-
 packages/frontend/src/components/MkMenu.child.vue  |   5 +-
 packages/frontend/src/components/MkMenu.vue        | 345 +++++++++++++--------
 packages/frontend/src/components/MkModal.vue       |  28 +-
 packages/frontend/src/components/MkModalWindow.vue |  13 +-
 packages/frontend/src/components/MkNote.vue        |  48 ++-
 .../frontend/src/components/MkNoteDetailed.vue     |  62 ++--
 packages/frontend/src/components/MkNotePreview.vue |   2 +-
 .../frontend/src/components/MkNotification.vue     |   2 +-
 packages/frontend/src/components/MkPagePreview.vue |  19 +-
 packages/frontend/src/components/MkPopupMenu.vue   |   6 +-
 packages/frontend/src/components/MkPostForm.vue    |  10 +
 .../frontend/src/components/MkPostFormDialog.vue   |   2 +-
 packages/frontend/src/components/MkRadio.vue       |  10 +-
 packages/frontend/src/components/MkSelect.vue      |  27 +-
 packages/frontend/src/components/MkSuperMenu.vue   |  10 +-
 packages/frontend/src/components/MkSwitch.vue      |  10 +-
 .../src/components/MkTutorialDialog.PostNote.vue   |   2 +-
 .../src/components/MkTutorialDialog.Sensitive.vue  |   2 +-
 .../src/components/MkTutorialDialog.Timeline.vue   |   2 +-
 .../frontend/src/components/MkVisibilityPicker.vue |   2 +-
 .../src/components/global/MkStickyContainer.vue    |   6 +-
 packages/frontend/src/directives/hotkey.ts         |   4 +-
 packages/frontend/src/os.ts                        |  27 +-
 packages/frontend/src/pages/drive.file.info.vue    |   1 +
 packages/frontend/src/pages/games.vue              |  11 +-
 packages/frontend/src/pages/page.vue               |   1 +
 packages/frontend/src/pages/settings/profile.vue   |   1 +
 packages/frontend/src/pages/settings/theme.vue     |   6 +
 packages/frontend/src/scripts/focus-trap.ts        |  65 ++++
 packages/frontend/src/scripts/focus.ts             |  96 ++++--
 .../frontend/src/scripts/get-dom-node-or-null.ts   |  19 ++
 packages/frontend/src/scripts/hotkey.ts            |  51 ++-
 packages/frontend/src/scripts/scroll.ts            |   8 +
 packages/frontend/src/style.scss                   |  25 +-
 packages/frontend/src/ui/_common_/common.vue       |   2 +-
 .../frontend/src/ui/_common_/navbar-for-mobile.vue |   6 +-
 packages/frontend/src/ui/_common_/navbar.vue       |  86 ++++-
 packages/frontend/src/ui/deck/column.vue           |   4 +-
 packages/frontend/src/widgets/WidgetCalendar.vue   |   2 +-
 61 files changed, 920 insertions(+), 379 deletions(-)
 create mode 100644 packages/frontend/src/scripts/focus-trap.ts
 create mode 100644 packages/frontend/src/scripts/get-dom-node-or-null.ts

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd123c938e..c6f48684b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@
 ### Client
 - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
 - Enhance: 非ログイン時のハイライトTLのデザインを改善
+- Enhance: フロントエンドのアクセシビリティ改善  
+  (Based on https://github.com/taiyme/misskey/pull/226)
 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
 - Fix: リバーシの対局を正しく共有できないことがある問題を修正
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue
index 5d103fa789..c8134416b5 100644
--- a/packages/frontend/src/components/MkAchievements.vue
+++ b/packages/frontend/src/components/MkAchievements.vue
@@ -153,7 +153,7 @@ onMounted(() => {
 		background: linear-gradient(0deg, #ffee20, #eb7018);
 	}
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		position: absolute;
@@ -173,7 +173,7 @@ onMounted(() => {
 		background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
 	}
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		position: absolute;
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 25b003ba5a..9560efb7d9 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -250,7 +250,6 @@ function onMousedown(evt: MouseEvent): void {
 	}
 
 	&:focus-visible {
-		outline: solid 2px var(--focus);
 		outline-offset: 2px;
 	}
 
diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue
index 841d37a568..35dc3ad4bf 100644
--- a/packages/frontend/src/components/MkChannelFollowButton.vue
+++ b/packages/frontend/src/components/MkChannelFollowButton.vue
@@ -87,17 +87,7 @@ async function onClick() {
 	}
 
 	&:focus-visible {
-		&:after {
-			content: "";
-			pointer-events: none;
-			position: absolute;
-			top: -5px;
-			right: -5px;
-			bottom: -5px;
-			left: -5px;
-			border: 2px solid var(--focus);
-			border-radius: 32px;
-		}
+		outline-offset: 2px;
 	}
 
 	&:hover {
diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue
index 4ff64dc4ba..c30cb66c07 100644
--- a/packages/frontend/src/components/MkChannelPreview.vue
+++ b/packages/frontend/src/components/MkChannelPreview.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div style="position: relative;">
-	<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt">
+	<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt">
 		<div class="banner" :style="bannerStyle">
 			<div class="fade"></div>
 			<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
@@ -80,6 +80,7 @@ const bannerStyle = computed(() => {
 <style lang="scss" scoped>
 .eftoefju {
 	display: block;
+	position: relative;
 	overflow: hidden;
 	width: 100%;
 
@@ -87,6 +88,22 @@ const bannerStyle = computed(() => {
 		text-decoration: none;
 	}
 
+	&:focus-within {
+		outline: none;
+
+		&::after {
+			content: '';
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			border-radius: inherit;
+			pointer-events: none;
+			box-shadow: inset 0 0 0 2px var(--focus);
+		}
+	}
+
 	> .banner {
 		position: relative;
 		width: 100%;
diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue
index 6299a28e9f..2e9a172c23 100644
--- a/packages/frontend/src/components/MkClipPreview.vue
+++ b/packages/frontend/src/components/MkClipPreview.vue
@@ -40,6 +40,14 @@ const remaining = computed(() => {
 .link {
 	display: block;
 
+	&:focus-visible {
+		outline: none;
+
+		.root {
+			box-shadow: inset 0 0 0 2px var(--focus);
+		}
+	}
+
 	&:hover {
 		text-decoration: none;
 		color: var(--accent);
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index a807742bb9..8ea8fa6cf3 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
 >
 	<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
-		<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
+		<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
 	</div>
 </Transition>
 </template>
diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue
index a2cb3185f4..b5f6e78b6c 100644
--- a/packages/frontend/src/components/MkCwButton.vue
+++ b/packages/frontend/src/components/MkCwButton.vue
@@ -45,11 +45,11 @@ function toggle() {
 .label {
 	margin-left: 4px;
 
-	&:before {
+	&::before {
 		content: '(';
 	}
 
-	&:after {
+	&::after {
 		content: ')';
 	}
 }
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index c52404a319..5c3c6aa51d 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
+<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()">
 	<div :class="$style.root">
 		<div v-if="icon" :class="$style.icon">
 			<i :class="icon"></i>
@@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
+import { ref, shallowRef, computed } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -156,10 +156,6 @@ function onBgClick() {
 	if (props.cancelableByBgClick) cancel();
 }
 */
-function onKeydown(evt: KeyboardEvent) {
-	if (evt.key === 'Escape') cancel();
-}
-
 function onInputKeydown(evt: KeyboardEvent) {
 	if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
 		evt.preventDefault();
@@ -167,14 +163,6 @@ function onInputKeydown(evt: KeyboardEvent) {
 		ok();
 	}
 }
-
-onMounted(() => {
-	document.addEventListener('keydown', onKeydown);
-});
-
-onBeforeUnmount(() => {
-	document.removeEventListener('keydown', onKeydown);
-});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index 4106b0a436..90284890a5 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -115,14 +115,14 @@ function onDragend() {
 		background: rgba(#000, 0.05);
 
 		> .label {
-			&:before,
-			&:after {
+			&::before,
+			&::after {
 				background: #0b65a5;
 			}
 
 			&.red {
-				&:before,
-				&:after {
+				&::before,
+				&::after {
 					background: #c12113;
 				}
 			}
@@ -133,14 +133,14 @@ function onDragend() {
 		background: rgba(#000, 0.1);
 
 		> .label {
-			&:before,
-			&:after {
+			&::before,
+			&::after {
 				background: #0b588c;
 			}
 
 			&.red {
-				&:before,
-				&:after {
+				&::before,
+				&::after {
 					background: #ce2212;
 				}
 			}
@@ -159,8 +159,8 @@ function onDragend() {
 		}
 
 		> .label {
-			&:before,
-			&:after {
+			&::before,
+			&::after {
 				display: none;
 			}
 		}
@@ -181,8 +181,8 @@ function onDragend() {
 	left: 0;
 	pointer-events: none;
 
-	&:before,
-	&:after {
+	&::before,
+	&::after {
 		content: "";
 		display: block;
 		position: absolute;
@@ -190,14 +190,14 @@ function onDragend() {
 		background: #0c7ac9;
 	}
 
-	&:before {
+	&::before {
 		top: 0;
 		left: 57px;
 		width: 28px;
 		height: 8px;
 	}
 
-	&:after {
+	&::after {
 		top: 57px;
 		left: 0;
 		width: 8px;
@@ -205,8 +205,8 @@ function onDragend() {
 	}
 
 	&.red {
-		&:before,
-		&:after {
+		&::before,
+		&::after {
 			background: #c12113;
 		}
 	}
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 1cc8b15b73..1790e57c24 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -296,7 +296,7 @@ function onContextmenu(ev: MouseEvent) {
 	cursor: pointer;
 
 	&.draghover {
-		&:after {
+		&::after {
 			content: "";
 			pointer-events: none;
 			position: absolute;
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 4bd4bee1e5..4a3ed69f47 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
-	<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
+	<input
+		ref="searchEl"
+		:value="q"
+		class="search"
+		data-prevent-emoji-insert
+		:class="{ filled: q != null && q != '' }"
+		:placeholder="i18n.ts.search"
+		type="search"
+		autocapitalize="off"
+		@input="input()"
+		@paste.stop="paste"
+		@keydown="onKeydown"
+	>
 	<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
 	<div ref="emojisEl" class="emojis" tabindex="-1">
 		<section class="result">
@@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{
 
 const emit = defineEmits<{
 	(ev: 'chosen', v: string): void;
+	(ev: 'esc'): void;
 }>();
 
 const searchEl = shallowRef<HTMLInputElement>();
@@ -433,9 +446,18 @@ function paste(event: ClipboardEvent): void {
 	}
 }
 
-function onEnter(ev: KeyboardEvent) {
+function onKeydown(ev: KeyboardEvent) {
 	if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
-	done();
+	if (ev.key === 'Enter') {
+		ev.preventDefault();
+		ev.stopPropagation();
+		done();
+	}
+	if (ev.key === 'Escape') {
+		ev.preventDefault();
+		ev.stopPropagation();
+		emit('esc');
+	}
 }
 
 function done(query?: string): boolean | void {
@@ -702,11 +724,6 @@ defineExpose({
 					border-radius: 4px;
 					font-size: 24px;
 
-					&:focus-visible {
-						outline: solid 2px var(--focus);
-						z-index: 1;
-					}
-
 					&:hover {
 						background: rgba(0, 0, 0, 0.05);
 					}
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index adcea839ee..a413b146ba 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:manualShowing="manualShowing"
 	:src="src"
 	@click="modal?.close()"
+	@esc="modal?.close()"
 	@opening="opening"
 	@close="emit('close')"
 	@closed="emit('closed')"
@@ -28,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		:asDrawer="type === 'drawer'"
 		:max-height="maxHeight"
 		@chosen="chosen"
+		@esc="modal?.close()"
 	/>
 </MkModal>
 </template>
diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue
index c5dd877971..6783804cc5 100644
--- a/packages/frontend/src/components/MkFlashPreview.vue
+++ b/packages/frontend/src/components/MkFlashPreview.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1">
+<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel">
 	<article>
 		<header>
 			<h1 :title="flash.title">{{ flash.title }}</h1>
@@ -39,6 +39,10 @@ const props = defineProps<{
 		color: var(--accent);
 	}
 
+	&:focus-visible {
+		outline-offset: -2px;
+	}
+
 	> article {
 		padding: 16px;
 
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 9b8eb19a11..f805be7b57 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
 	<MkStickyContainer>
 		<template #header>
-			<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
+			<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
 				<div :class="$style.headerIcon"><slot name="icon"></slot></div>
 				<div :class="$style.headerText">
-					<div>
+					<div :class="$style.headerTextMain">
 						<MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
 					</div>
 					<div :class="$style.headerTextSub">
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i v-if="opened" class="ti ti-chevron-up icon"></i>
 					<i v-else class="ti ti-chevron-down icon"></i>
 				</div>
-			</div>
+			</button>
 		</template>
 
 		<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
@@ -147,6 +147,10 @@ onMounted(() => {
 		background: var(--buttonHoverBg);
 	}
 
+	&:focus-within {
+		outline-offset: 2px;
+	}
+
 	&.active {
 		color: var(--accent);
 		background: var(--buttonHoverBg);
@@ -190,6 +194,12 @@ onMounted(() => {
 	padding-right: 12px;
 }
 
+.headerTextMain,
+.headerTextSub {
+	width: fit-content;
+	max-width: 100%;
+}
+
 .headerTextSub {
 	color: var(--fgTransparentWeak);
 	font-size: .85em;
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index 6a4081079c..ea76950c0d 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -185,17 +185,7 @@ onBeforeUnmount(() => {
 	}
 
 	&:focus-visible {
-		&:after {
-			content: "";
-			pointer-events: none;
-			position: absolute;
-			top: -5px;
-			right: -5px;
-			bottom: -5px;
-			left: -5px;
-			border: 2px solid var(--focus);
-			border-radius: 32px;
-		}
+		outline-offset: 2px;
 	}
 
 	&:hover {
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
index 47cccd9b7c..2bb5b8762a 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.vue
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -83,7 +83,7 @@ function leaveHover(): void {
 
 		> article {
 			> footer {
-				&:before {
+				&::before {
 					opacity: 1;
 				}
 			}
@@ -139,7 +139,7 @@ function leaveHover(): void {
 			text-shadow: 0 0 8px #000;
 			background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
 
-			&:before {
+			&::before {
 				content: "";
 				display: block;
 				position: absolute;
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 4e3fafe845..617404f5c4 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 		:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
 		:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
 	>
-		<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
-		<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
+		<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"/>
 	</TransitionGroup>
 </div>
 </template>
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index f9d4334c4c..8e3c19bd12 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')">
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
 	<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
 		<div class="main">
 			<template v-for="item in items" :key="item.text">
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index e8dfcc7768..582cf238c0 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -39,23 +39,37 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<audio
 			ref="audioEl"
 			preload="metadata"
+			@keydown.prevent="() => {}"
 		>
 			<source :src="audio.url">
 		</audio>
 		<div :class="[$style.controlsChild, $style.controlsLeft]">
-			<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
+			<button
+				:class="['_button', $style.controlButton]"
+				tabindex="-1"
+				@click.stop="togglePlayPause"
+			>
 				<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
 				<i v-else class="ti ti-player-play-filled"></i>
 			</button>
 		</div>
 		<div :class="[$style.controlsChild, $style.controlsRight]">
-			<button class="_button" :class="$style.controlButton" @click="showMenu">
+			<button
+				:class="['_button', $style.controlButton]"
+				tabindex="-1"
+				@click.stop="() => {}"
+				@mousedown.prevent.stop="showMenu"
+			>
 				<i class="ti ti-settings"></i>
 			</button>
 		</div>
 		<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
 		<div :class="[$style.controlsChild, $style.controlsVolume]">
-			<button class="_button" :class="$style.controlButton" @click="toggleMute">
+			<button
+				:class="['_button', $style.controlButton]"
+				tabindex="-1"
+				@click.stop="toggleMute"
+			>
 				<i v-if="volume === 0" class="ti ti-volume-3"></i>
 				<i v-else class="ti ti-volume"></i>
 			</button>
@@ -371,7 +385,7 @@ onDeactivated(() => {
 	border-radius: var(--radius);
 	overflow: clip;
 
-	&:focus {
+	&:focus-visible {
 		outline: none;
 	}
 }
@@ -437,6 +451,10 @@ onDeactivated(() => {
 			color: var(--accent);
 			background-color: var(--accentedBg);
 		}
+
+		&:focus-visible {
+			outline: none;
+		}
 	}
 }
 
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index b1321a8ef9..24b177d255 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -39,6 +39,7 @@ import XVideo from '@/components/MkMediaVideo.vue';
 import * as os from '@/os.js';
 import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
 import { defaultStore } from '@/store.js';
+import { focusParent } from '@/scripts/focus.js';
 
 const props = defineProps<{
 	mediaList: Misskey.entities.DriveFile[];
@@ -49,7 +50,9 @@ const gallery = shallowRef<HTMLDivElement>();
 const pswpZIndex = os.claimZIndex('middle');
 document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
 const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
-let lightbox: PhotoSwipeLightbox | null;
+let lightbox: PhotoSwipeLightbox | null = null;
+
+let activeEl: HTMLElement | null = null;
 
 const popstateHandler = (): void => {
 	if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
@@ -60,7 +63,7 @@ const popstateHandler = (): void => {
 async function calcAspectRatio() {
 	if (!gallery.value) return;
 
-	let img = props.mediaList[0];
+	const img = props.mediaList[0];
 
 	if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
 		gallery.value.style.aspectRatio = '';
@@ -131,6 +134,7 @@ onMounted(() => {
 		bgOpacity: 1,
 		showAnimationDuration: 100,
 		hideAnimationDuration: 100,
+		returnFocus: false,
 		pswpModule: PhotoSwipe,
 	});
 
@@ -159,39 +163,47 @@ onMounted(() => {
 	lightbox.on('uiRegister', () => {
 		lightbox?.pswp?.ui?.registerElement({
 			name: 'altText',
-			className: 'pwsp__alt-text-container',
+			className: 'pswp__alt-text-container',
 			appendTo: 'wrapper',
-			onInit: (el, pwsp) => {
-				let textBox = document.createElement('p');
-				textBox.className = 'pwsp__alt-text _acrylic';
+			onInit: (el, pswp) => {
+				const textBox = document.createElement('p');
+				textBox.className = 'pswp__alt-text _acrylic';
 				el.appendChild(textBox);
 
-				pwsp.on('change', () => {
-					textBox.textContent = pwsp.currSlide?.data.comment;
+				pswp.on('change', () => {
+					textBox.textContent = pswp.currSlide?.data.comment;
 				});
 			},
 		});
 	});
 
-	lightbox.init();
-
-	window.addEventListener('popstate', popstateHandler);
-
-	lightbox.on('beforeOpen', () => {
+	lightbox.on('afterInit', () => {
+		activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+		focusParent(activeEl, true, true);
+		lightbox?.pswp?.element?.focus({
+			preventScroll: true,
+		});
 		history.pushState(null, '', '#pswp');
 	});
 
-	lightbox.on('close', () => {
+	lightbox.on('destroy', () => {
+		focusParent(activeEl, true, false);
+		activeEl = null;
 		if (window.location.hash === '#pswp') {
 			history.back();
 		}
 	});
+
+	window.addEventListener('popstate', popstateHandler);
+
+	lightbox.init();
 });
 
 onUnmounted(() => {
 	window.removeEventListener('popstate', popstateHandler);
 	lightbox?.destroy();
 	lightbox = null;
+	activeEl = null;
 });
 
 const previewable = (file: Misskey.entities.DriveFile): boolean => {
@@ -199,6 +211,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
 	// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
 	return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
 };
+
+const openGallery = () => {
+	if (props.mediaList.filter(media => previewable(media)).length > 0) {
+		lightbox?.loadAndOpen(0);
+	}
+};
+
+defineExpose({
+	openGallery,
+});
 </script>
 
 <style lang="scss" module>
@@ -298,7 +320,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
 	backdrop-filter: var(--modalBgFilter);
 }
 
-.pwsp__alt-text-container {
+.pswp__alt-text-container {
 	display: flex;
 	flex-direction: row;
 	align-items: center;
@@ -312,7 +334,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
 	max-width: 800px;
 }
 
-.pwsp__alt-text {
+.pswp__alt-text {
 	color: var(--fg);
 	margin: 0 auto;
 	text-align: center;
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 7c46084c63..0ec0039df4 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -481,7 +481,7 @@ onDeactivated(() => {
 	position: relative;
 	overflow: clip;
 
-	&:focus {
+	&:focus-visible {
 		outline: none;
 	}
 }
@@ -588,6 +588,10 @@ onDeactivated(() => {
 	border-radius: 99rem;
 
 	font-size: 1.1rem;
+
+	&:focus-visible {
+		outline: none;
+	}
 }
 
 .videoLoading {
@@ -651,6 +655,10 @@ onDeactivated(() => {
 		&:hover {
 			background-color: var(--accent);
 		}
+
+		&:focus-visible {
+			outline: none;
+		}
 	}
 }
 
diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue
index dfb6d34618..235790556c 100644
--- a/packages/frontend/src/components/MkMenu.child.vue
+++ b/packages/frontend/src/components/MkMenu.child.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
+import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
 import MkMenu from './MkMenu.vue';
 import { MenuItem } from '@/types/menu.js';
 
@@ -19,7 +19,6 @@ const props = defineProps<{
 	targetElement: HTMLElement;
 	rootElement: HTMLElement;
 	width?: number;
-	viaKeyboard?: boolean;
 }>();
 
 const emit = defineEmits<{
@@ -27,6 +26,8 @@ const emit = defineEmits<{
 	(ev: 'actioned'): void;
 }>();
 
+provide('isNestingMenu', true);
+
 const el = shallowRef<HTMLElement>();
 const align = 'left';
 
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index 119504f744..68479989b2 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -4,23 +4,42 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div role="menu">
+<div role="menu" @focusin.passive.stop="() => {}">
 	<div
-		ref="itemsEl" v-hotkey="keymap"
+		ref="itemsEl"
+		v-hotkey="keymap"
+		tabindex="0"
 		class="_popup _shadow"
-		:class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer }]"
-		:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
-		@contextmenu.self="e => e.preventDefault()"
+		:class="{
+			[$style.root]: true,
+			[$style.center]: align === 'center',
+			[$style.asDrawer]: asDrawer,
+		}"
+		:style="{
+			width: (width && !asDrawer) ? `${width}px` : '',
+			maxHeight: maxHeight ? `${maxHeight}px` : '',
+		}"
+		@keydown.stop="() => {}"
+		@contextmenu.self.prevent="() => {}"
 	>
-		<template v-for="(item, i) in (items2 ?? [])">
-			<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
-			<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
+		<template v-for="item in (items2 ?? [])">
+			<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
+			<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
 				<span style="opacity: 0.7;">{{ item.text }}</span>
 			</span>
-			<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
+			<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
 				<span><MkEllipsis/></span>
 			</span>
-			<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<MkA
+				v-else-if="item.type === 'link'"
+				role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item]"
+				:to="item.to"
+				@click.passive="close(true)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 				<div :class="$style.item_content">
@@ -28,20 +47,48 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 				</div>
 			</MkA>
-			<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<a
+				v-else-if="item.type === 'a'"
+				role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item]"
+				:href="item.href"
+				:target="item.target"
+				:download="item.download"
+				@click.passive="close(true)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<div :class="$style.item_content">
 					<span :class="$style.item_content_text">{{ item.text }}</span>
 					<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 				</div>
 			</a>
-			<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<button
+				v-else-if="item.type === 'user'"
+				role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item, { [$style.active]: item.active }]"
+				@click.prevent="item.active ? close(false) : clicked(item.action, $event)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
 				<div v-if="item.indicate" :class="$style.item_content">
 					<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 				</div>
 			</button>
-			<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<button
+				v-else-if="item.type === 'switch'"
+				role="menuitemcheckbox"
+				tabindex="0"
+				:class="['_button', $style.item]"
+				:disabled="unref(item.disabled)"
+				@click.prevent="switchItem(item)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
 				<div :class="$style.item_content">
@@ -49,29 +96,61 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
 				</div>
 			</button>
-			<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
+			<button
+				v-else-if="item.type === 'radio'"
+				role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
+				:disabled="unref(item.disabled)"
+				@mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)"
+				@keydown.enter.prevent="preferClick ? null : showRadioOptions(item, $event)"
+				@click.prevent="!preferClick ? null : showRadioOptions(item, $event)"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
 				<div :class="$style.item_content">
 					<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
 					<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
 				</div>
 			</button>
-			<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<button
+				v-else-if="item.type === 'radioOption'"
+				role="menuitemradio"
+				tabindex="0"
+				:class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]"
+				@click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<div :class="$style.icon">
-					<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
+					<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
 				</div>
 				<div :class="$style.item_content">
 					<span :class="$style.item_content_text">{{ item.text }}</span>
 				</div>
 			</button>
-			<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
+			<button
+				v-else-if="item.type === 'parent'"
+				role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
+				@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
+				@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
+				@click.prevent="!preferClick ? null : showChildren(item, $event)"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
 				<div :class="$style.item_content">
 					<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
 					<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
 				</div>
 			</button>
-			<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
+			<button
+				v-else role="menuitem"
+				tabindex="0"
+				:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
+				@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
+				@mouseenter.passive="onItemMouseEnter"
+				@mouseleave.passive="onItemMouseLeave"
+			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 				<div :class="$style.item_content">
@@ -80,25 +159,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</button>
 		</template>
-		<span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]">
+		<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
 			<span>{{ i18n.ts.none }}</span>
 		</span>
 	</div>
 	<div v-if="childMenu">
-		<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/>
+		<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
 	</div>
 </div>
 </template>
 
 <script lang="ts">
-import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
-import { focusPrev, focusNext } from '@/scripts/focus.js';
+import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
 import MkSwitchButton from '@/components/MkSwitch.button.vue';
 import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { isTouchUsing } from '@/scripts/touch.js';
 import { type Keymap } from '@/scripts/hotkey.js';
+import { isFocusable } from '@/scripts/focus.js';
+import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
 
 const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
 </script>
@@ -108,7 +188,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
 
 const props = defineProps<{
 	items: MenuItem[];
-	viaKeyboard?: boolean;
 	asDrawer?: boolean;
 	align?: 'center' | string;
 	width?: number;
@@ -120,7 +199,9 @@ const emit = defineEmits<{
 	(ev: 'hide'): void;
 }>();
 
-const itemsEl = shallowRef<HTMLDivElement>();
+const isNestingMenu = inject<boolean>('isNestingMenu', false);
+
+const itemsEl = shallowRef<HTMLElement>();
 
 const items2 = ref<InnerMenuItem[]>();
 
@@ -177,25 +258,19 @@ function childActioned() {
 	close(true);
 }
 
-const onGlobalMousedown = (event: MouseEvent) => {
-	if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return;
-	if (child.value && child.value.checkHit(event)) return;
-	closeChild();
-};
-
 let childCloseTimer: null | number = null;
 
-function onItemMouseEnter(item) {
+function onItemMouseEnter() {
 	childCloseTimer = window.setTimeout(() => {
 		closeChild();
 	}, 300);
 }
 
-function onItemMouseLeave(item) {
+function onItemMouseLeave() {
 	if (childCloseTimer) window.clearTimeout(childCloseTimer);
 }
 
-async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
+async function showRadioOptions(item: MenuRadio, ev: Event) {
 	const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
 		const value = item.options[key];
 		return {
@@ -210,7 +285,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
 
 	if (props.asDrawer) {
 		os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
-			emit('close');
+			close(false);
 		});
 		emit('hide');
 	} else {
@@ -220,7 +295,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
 	}
 }
 
-async function showChildren(item: MenuParent, ev: MouseEvent) {
+async function showChildren(item: MenuParent, ev: Event) {
 	const children: MenuItem[] = await (async () => {
 		if (childrenCache.has(item)) {
 			return childrenCache.get(item)!;
@@ -237,7 +312,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
 
 	if (props.asDrawer) {
 		os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
-			emit('close');
+			close(false);
 		});
 		emit('hide');
 	} else {
@@ -256,41 +331,87 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
 }
 
 function close(actioned = false) {
-	emit('close', actioned);
+	disposeHandlers();
+	nextTick(() => {
+		closeChild();
+		emit('close', actioned);
+	});
+}
+
+function switchItem(item: MenuSwitch & { ref: any }) {
+	if (item.disabled !== undefined && (typeof item.disabled === 'boolean' ? item.disabled : item.disabled.value)) return;
+	item.ref = !item.ref;
 }
 
 function focusUp() {
-	focusPrev(document.activeElement);
+	if (disposed) return;
+	if (!itemsEl.value?.contains(document.activeElement)) return;
+
+	const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
+	const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
+	const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
+	const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
+
+	targetElement.focus();
 }
 
 function focusDown() {
-	focusNext(document.activeElement);
-}
+	if (disposed) return;
+	if (!itemsEl.value?.contains(document.activeElement)) return;
 
-function switchItem(item: MenuSwitch & { ref: any }) {
-	if (item.disabled !== undefined && (typeof item.disabled === 'boolean' ? item.disabled : item.disabled.value)) return;
-	item.ref = !item.ref;
-}
+	const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
+	const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
+	const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
+	const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
 
-function getValue<T>(item?: ComputedRef<T> | T) {
-	return isRef(item) ? item.value : item;
+	targetElement.focus();
 }
 
-onMounted(() => {
-	if (props.viaKeyboard) {
-		nextTick(() => {
-			if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
-		});
-	}
+const onGlobalFocusin = (ev: FocusEvent) => {
+	if (disposed) return;
+	if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return;
+	nextTick(() => {
+		if (itemsEl.value != null && isFocusable(itemsEl.value)) {
+			itemsEl.value.focus({ preventScroll: true });
+			nextTick(() => focusDown());
+		}
+	});
+};
 
-	// TODO: アクティブな要素までスクロール
-	//itemsEl.scrollTo();
+const onGlobalMousedown = (ev: MouseEvent) => {
+	if (disposed) return;
+	if (childTarget.value?.contains(getNodeOrNull(ev.target))) return;
+	if (child.value?.checkHit(ev)) return;
+	closeChild();
+};
 
+const setupHandlers = () => {
+	if (!isNestingMenu) {
+		document.addEventListener('focusin', onGlobalFocusin, { passive: true });
+	}
 	document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
+};
+
+let disposed = false;
+
+const disposeHandlers = () => {
+	disposed = true;
+	if (!isNestingMenu) {
+		document.removeEventListener('focusin', onGlobalFocusin);
+	}
+	document.removeEventListener('mousedown', onGlobalMousedown);
+};
+
+onMounted(() => {
+	setupHandlers();
+
+	if (!isNestingMenu) {
+		nextTick(() => itemsEl.value?.focus({ preventScroll: true }));
+	}
 });
 
 onBeforeUnmount(() => {
-	document.removeEventListener('mousedown', onGlobalMousedown);
+	disposeHandlers();
 });
 </script>
 
@@ -303,6 +424,10 @@ onBeforeUnmount(() => {
 	overflow: auto;
 	overscroll-behavior: contain;
 
+	&:focus-visible {
+		outline: none;
+	}
+
 	&.center {
 		> .item {
 			text-align: center;
@@ -320,7 +445,7 @@ onBeforeUnmount(() => {
 			font-size: 1em;
 			padding: 12px 24px;
 
-			&:before {
+			&::before {
 				width: calc(100% - 24px);
 				border-radius: 12px;
 			}
@@ -350,8 +475,10 @@ onBeforeUnmount(() => {
 	text-align: left;
 	overflow: hidden;
 	text-overflow: ellipsis;
+	text-decoration: none !important;
+	color: var(--menuFg, var(--fg));
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		position: absolute;
@@ -365,56 +492,56 @@ onBeforeUnmount(() => {
 		border-radius: 6px;
 	}
 
-	&:not(:disabled):hover {
-		color: var(--accent);
-		text-decoration: none;
+	&:focus-visible {
+		outline: none;
 
-		&:before {
-			background: var(--accentedBg);
+		&:not(:hover):not(:active)::before {
+			outline: var(--focus) solid 2px;
+			outline-offset: -2px;
 		}
 	}
 
-	&.danger {
-		color: #ff2a2a;
-
-		&:hover {
-			color: #fff;
+	&:not(:disabled) {
+		&:hover,
+		&:focus-visible:active,
+		&:focus-visible.active {
+			color: var(--menuHoverFg, var(--accent));
 
-			&:before {
-				background: #ff4242;
+			&::before {
+				background-color: var(--menuHoverBg, var(--accentedBg));
 			}
 		}
 
-		&:active {
-			color: #fff;
+		&:not(:focus-visible):active,
+		&:not(:focus-visible).active {
+			color: var(--menuActiveFg, var(--fgOnAccent));
 
-			&:before {
-				background: #d42e2e !important;
+			&::before {
+				background-color: var(--menuActiveBg, var(--accent));
 			}
 		}
 	}
 
-	&:active,
-	&.active {
-		color: var(--fgOnAccent) !important;
-		opacity: 1;
-
-		&:before {
-			background: var(--accent) !important;
-		}
+	&:disabled {
+		cursor: not-allowed;
 	}
 
-	&.radioActive {
-		color: var(--accent) !important;
-		opacity: 1;
+	&.danger {
+		--menuFg: #ff2a2a;
+		--menuHoverFg: #fff;
+		--menuHoverBg: #ff4242;
+		--menuActiveFg: #fff;
+		--menuActiveBg: #d42e2e;
+	}
 
-		&:before {
-			background-color: var(--accentedBg) !important;
-		}
+	&.radio {
+		--menuActiveFg: var(--accent);
+		--menuActiveBg: var(--accentedBg);
 	}
 
-	&:not(:active):focus-visible {
-		box-shadow: 0 0 0 2px var(--focus) inset;
+	&.parent {
+		--menuActiveFg: var(--accent);
+		--menuActiveBg: var(--accentedBg);
 	}
 
 	&.label {
@@ -432,22 +559,6 @@ onBeforeUnmount(() => {
 		pointer-events: none;
 		opacity: 0.7;
 	}
-
-	&.parent {
-		pointer-events: auto;
-		display: flex;
-		align-items: center;
-		cursor: default;
-
-		&.childShowing {
-			color: var(--accent);
-			text-decoration: none;
-
-			&:before {
-				background: var(--accentedBg);
-			}
-		}
-	}
 }
 
 .item_content {
@@ -466,18 +577,6 @@ onBeforeUnmount(() => {
 	overflow: hidden;
 }
 
-.switch {
-	position: relative;
-	display: flex;
-	transition: all 0.2s ease;
-	user-select: none;
-	cursor: pointer;
-}
-
-.switchDisabled {
-	cursor: not-allowed;
-}
-
 .switchButton {
 	margin-left: -2px;
 	--height: 1.35em;
@@ -489,14 +588,6 @@ onBeforeUnmount(() => {
 	text-overflow: ellipsis;
 }
 
-.switchInput {
-	position: absolute;
-	width: 0;
-	height: 0;
-	opacity: 0;
-	margin: 0;
-}
-
 .icon {
 	margin-right: 8px;
 	line-height: 1;
@@ -525,12 +616,12 @@ onBeforeUnmount(() => {
 	border-top: solid 0.5px var(--divider);
 }
 
-.radio {
+.radioIcon {
 	display: inline-block;
 	position: relative;
 	width: 1em;
 	height: 1em;
-	vertical-align: -.125em;
+	vertical-align: -0.125em;
 	border-radius: 50%;
 	border: solid 2px var(--divider);
 	background-color: var(--panel);
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 264d8b6c9c..a5fbf8d365 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -30,9 +30,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 		[$style.transition_modal_leaveTo]: transitionName === 'modal',
 		[$style.transition_send_leaveTo]: transitionName === 'send',
 	})"
-	:duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
+	:duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened"
 >
-	<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+	<div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
 		<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
 		<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
 			<slot :max-height="maxHeight" :type="type"></slot>
@@ -48,6 +48,8 @@ import { isTouchUsing } from '@/scripts/touch.js';
 import { defaultStore } from '@/store.js';
 import { deviceKind } from '@/scripts/device-kind.js';
 import { type Keymap } from '@/scripts/hotkey.js';
+import { focusTrap } from '@/scripts/focus-trap.js';
+import { focusParent } from '@/scripts/focus.js';
 
 function getFixedContainer(el: Element | null): Element | null {
 	if (el == null || el.tagName === 'BODY') return null;
@@ -69,6 +71,7 @@ const props = withDefaults(defineProps<{
 	zPriority?: 'low' | 'middle' | 'high';
 	noOverlap?: boolean;
 	transparentBg?: boolean;
+	returnFocusTo?: HTMLElement | null;
 }>(), {
 	manualShowing: null,
 	src: null,
@@ -77,6 +80,7 @@ const props = withDefaults(defineProps<{
 	zPriority: 'low',
 	noOverlap: true,
 	transparentBg: false,
+	returnFocusTo: null,
 });
 
 const emit = defineEmits<{
@@ -94,6 +98,7 @@ const maxHeight = ref<number>();
 const fixed = ref(false);
 const transformOrigin = ref('center');
 const showing = ref(true);
+const modalRootEl = shallowRef<HTMLElement>();
 const content = shallowRef<HTMLElement>();
 const zIndex = os.claimZIndex(props.zPriority);
 const useSendAnime = ref(false);
@@ -132,6 +137,7 @@ const transitionDuration = computed((() =>
 					: 0
 ));
 
+let releaseFocusTrap: (() => void) | null = null;
 let contentClicking = false;
 
 function close(opts: { useSendAnimation?: boolean } = {}) {
@@ -296,6 +302,10 @@ const onOpened = () => {
 	}, { passive: true });
 };
 
+const onClosed = () => {
+	emit('closed');
+};
+
 const alignObserver = new ResizeObserver((entries, observer) => {
 	align();
 });
@@ -313,6 +323,20 @@ onMounted(() => {
 		align();
 	}, { immediate: true });
 
+	watch([showing, () => props.manualShowing], ([showing, manualShowing]) => {
+		if (manualShowing === true || (manualShowing == null && showing === true)) {
+			if (modalRootEl.value != null) {
+				const { release } = focusTrap(modalRootEl.value);
+
+				releaseFocusTrap = release;
+				modalRootEl.value.focus();
+			}
+		} else {
+			releaseFocusTrap?.();
+			focusParent(props.returnFocusTo ?? props.src, true, false);
+		}
+	}, { immediate: true });
+
 	nextTick(() => {
 		alignObserver.observe(content.value!);
 	});
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index d3657afa94..78053c8cfd 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
-	<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
+<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
+	<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
 		<div ref="headerEl" :class="$style.header">
 			<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
 			<span :class="$style.title">
@@ -42,6 +42,7 @@ const emit = defineEmits<{
 	(event: 'close'): void;
 	(event: 'closed'): void;
 	(event: 'ok'): void;
+	(event: 'esc'): void;
 }>();
 
 const modal = shallowRef<InstanceType<typeof MkModal>>();
@@ -58,14 +59,6 @@ const onBgClick = () => {
 	emit('click');
 };
 
-const onKeydown = (evt) => {
-	if (evt.which === 27) { // Esc
-		evt.preventDefault();
-		evt.stopPropagation();
-		close();
-	}
-};
-
 const ro = new ResizeObserver((entries, observer) => {
 	if (rootEl.value == null || headerEl.value == null) return;
 	bodyWidth.value = rootEl.value.offsetWidth;
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index fc72813285..420ff2c651 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	ref="rootEl"
 	v-hotkey="keymap"
 	:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
-	:tabindex="!isDeleted ? '-1' : undefined"
+	:tabindex="isDeleted ? '-1' : '0'"
 >
 	<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
 	<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</template>
 		</I18n>
 		<div :class="$style.renoteInfo">
-			<button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()">
+			<button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()">
 				<i class="ti ti-dots" :class="$style.renoteMenu"></i>
 				<MkTime :time="note.createdAt"/>
 			</button>
@@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</div>
 					</div>
 					<div v-if="appearNote.files && appearNote.files.length > 0">
-						<MkMediaList :mediaList="appearNote.files"/>
+						<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
 					</div>
 					<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
 					<div v-if="isEnabledUrlPreview">
@@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					ref="renoteButton"
 					:class="$style.footerButton"
 					class="_button"
-					@mousedown="renote()"
+					@mousedown.prevent="renote()"
 				>
 					<i class="ti ti-repeat"></i>
 					<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
@@ -125,10 +125,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<i v-else class="ti ti-plus"></i>
 					<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
 				</button>
-				<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
+				<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
 					<i class="ti ti-paperclip"></i>
 				</button>
-				<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
+				<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
 					<i class="ti ti-dots"></i>
 				</button>
 			</footer>
@@ -175,7 +175,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
 import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
 import { pleaseLogin } from '@/scripts/please-login.js';
-import { focusPrev, focusNext } from '@/scripts/focus.js';
 import { checkWordMute } from '@/scripts/check-word-mute.js';
 import { userPage } from '@/filters/user.js';
 import number from '@/filters/number.js';
@@ -199,6 +198,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 import { shouldCollapsed } from '@/scripts/collapsed.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { type Keymap } from '@/scripts/hotkey.js';
+import { focusPrev, focusNext } from '@/scripts/focus.js';
 
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
@@ -257,6 +257,7 @@ const renoteTime = shallowRef<HTMLElement>();
 const reactButton = shallowRef<HTMLElement>();
 const clipButton = shallowRef<HTMLElement>();
 const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
+const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
@@ -318,7 +319,7 @@ const keymap = {
 	},
 	'o': () => {
 		if (renoteCollapsed.value) return;
-		showMenu();
+		galleryEl.value?.openGallery();
 	},
 	'v|enter': () => {
 		if (renoteCollapsed.value) {
@@ -419,7 +420,7 @@ function renote(viaKeyboard = false) {
 	});
 }
 
-function reply(viaKeyboard = false): void {
+function reply(): void {
 	pleaseLogin();
 	if (props.mock) {
 		return;
@@ -427,13 +428,12 @@ function reply(viaKeyboard = false): void {
 	os.post({
 		reply: appearNote.value,
 		channel: appearNote.value.channel,
-		animation: !viaKeyboard,
 	}).then(() => {
 		focus();
 	});
 }
 
-function react(viaKeyboard = false): void {
+function react(): void {
 	pleaseLogin();
 	showMovedDialog();
 	if (appearNote.value.reactionAcceptance === 'likeOnly') {
@@ -528,18 +528,16 @@ function onContextmenu(ev: MouseEvent): void {
 	}
 }
 
-function showMenu(viaKeyboard = false): void {
+function showMenu(): void {
 	if (props.mock) {
 		return;
 	}
 
 	const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
-	os.popupMenu(menu, menuButton.value, {
-		viaKeyboard,
-	}).then(focus).finally(cleanup);
+	os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
 }
 
-async function clip() {
+async function clip(): Promise<void> {
 	if (props.mock) {
 		return;
 	}
@@ -547,7 +545,7 @@ async function clip() {
 	os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
 }
 
-function showRenoteMenu(viaKeyboard = false): void {
+function showRenoteMenu(): void {
 	if (props.mock) {
 		return;
 	}
@@ -572,18 +570,14 @@ function showRenoteMenu(viaKeyboard = false): void {
 			getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
 			{ type: 'divider' },
 			getUnrenote(),
-		], renoteTime.value, {
-			viaKeyboard: viaKeyboard,
-		});
+		], renoteTime.value);
 	} else {
 		os.popupMenu([
 			getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
 			{ type: 'divider' },
 			getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
 			($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
-		], renoteTime.value, {
-			viaKeyboard: viaKeyboard,
-		});
+		], renoteTime.value);
 	}
 }
 
@@ -596,11 +590,11 @@ function blur() {
 }
 
 function focusBefore() {
-	focusPrev(rootEl.value ?? null);
+	focusPrev(rootEl.value);
 }
 
 function focusAfter() {
-	focusNext(rootEl.value ?? null);
+	focusNext(rootEl.value);
 }
 
 function readPromo() {
@@ -638,7 +632,7 @@ function emitUpdReaction(emoji: string, delta: number) {
 	&:focus-visible {
 		outline: none;
 
-		&:after {
+		&::after {
 			content: "";
 			pointer-events: none;
 			display: block;
@@ -651,7 +645,7 @@ function emitUpdReaction(emoji: string, delta: number) {
 			margin: auto;
 			width: calc(100% - 8px);
 			height: calc(100% - 8px);
-			border: dashed 1px var(--focus);
+			border: dashed 2px var(--focus);
 			border-radius: var(--radius);
 			box-sizing: border-box;
 		}
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 8f65e3b60a..a8fed56c39 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	ref="rootEl"
 	v-hotkey="keymap"
 	:class="$style.root"
+	:tabindex="isDeleted ? '-1' : '0'"
 >
 	<div v-if="appearNote.reply && appearNote.reply.replyId">
 		<div v-if="!conversationLoaded" style="padding: 16px">
@@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</I18n>
 		</span>
 		<div :class="$style.renoteInfo">
-			<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
+			<button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()">
 				<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
 				<MkTime :time="note.createdAt"/>
 			</button>
@@ -92,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 				</div>
 				<div v-if="appearNote.files && appearNote.files.length > 0">
-					<MkMediaList :mediaList="appearNote.files"/>
+					<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
 				</div>
 				<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
 				<div v-if="isEnabledUrlPreview">
@@ -118,7 +119,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				ref="renoteButton"
 				class="_button"
 				:class="$style.noteFooterButton"
-				@mousedown="renote()"
+				@mousedown.prevent="renote()"
 			>
 				<i class="ti ti-repeat"></i>
 				<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
@@ -133,10 +134,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-else class="ti ti-plus"></i>
 				<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
 			</button>
-			<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
+			<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
 				<i class="ti ti-paperclip"></i>
 			</button>
-			<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()">
+			<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
 				<i class="ti ti-dots"></i>
 			</button>
 		</footer>
@@ -281,6 +282,7 @@ const renoteTime = shallowRef<HTMLElement>();
 const reactButton = shallowRef<HTMLElement>();
 const clipButton = shallowRef<HTMLElement>();
 const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
+const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const isDeleted = ref(false);
@@ -303,6 +305,7 @@ const keymap = {
 		if (!defaultStore.state.showClipButtonInNoteFooter) return;
 		clip();
 	},
+	'o': () => galleryEl.value?.openGallery(),
 	'v|enter': () => {
 		if (appearNote.value.cw != null) {
 			showContent.value = !showContent.value;
@@ -392,29 +395,26 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
 	});
 }
 
-function renote(viaKeyboard = false) {
+function renote() {
 	pleaseLogin();
 	showMovedDialog();
 
 	const { menu } = getRenoteMenu({ note: note.value, renoteButton });
-	os.popupMenu(menu, renoteButton.value, {
-		viaKeyboard,
-	});
+	os.popupMenu(menu, renoteButton.value);
 }
 
-function reply(viaKeyboard = false): void {
+function reply(): void {
 	pleaseLogin();
 	showMovedDialog();
 	os.post({
 		reply: appearNote.value,
 		channel: appearNote.value.channel,
-		animation: !viaKeyboard,
 	}).then(() => {
 		focus();
 	});
 }
 
-function react(viaKeyboard = false): void {
+function react(): void {
 	pleaseLogin();
 	showMovedDialog();
 	if (appearNote.value.reactionAcceptance === 'likeOnly') {
@@ -424,7 +424,7 @@ function react(viaKeyboard = false): void {
 			noteId: appearNote.value.id,
 			reaction: '❤️',
 		});
-		const el = reactButton.value as HTMLElement | null | undefined;
+		const el = reactButton.value;
 		if (el) {
 			const rect = el.getBoundingClientRect();
 			const x = rect.left + (el.offsetWidth / 2);
@@ -488,18 +488,16 @@ function onContextmenu(ev: MouseEvent): void {
 	}
 }
 
-function showMenu(viaKeyboard = false): void {
+function showMenu(): void {
 	const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
-	os.popupMenu(menu, menuButton.value, {
-		viaKeyboard,
-	}).then(focus).finally(cleanup);
+	os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
 }
 
-async function clip() {
+async function clip(): Promise<void> {
 	os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
 }
 
-function showRenoteMenu(viaKeyboard = false): void {
+function showRenoteMenu(): void {
 	if (!isMyRenote) return;
 	pleaseLogin();
 	os.popupMenu([{
@@ -512,9 +510,7 @@ function showRenoteMenu(viaKeyboard = false): void {
 			});
 			isDeleted.value = true;
 		},
-	}], renoteTime.value, {
-		viaKeyboard: viaKeyboard,
-	});
+	}], renoteTime.value);
 }
 
 function focus() {
@@ -556,6 +552,28 @@ function loadConversation() {
 	transition: box-shadow 0.1s ease;
 	overflow: clip;
 	contain: content;
+
+	&: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;
+		}
+	}
 }
 
 .replyTo {
diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue
index cc2f770cda..c4479bb0d6 100644
--- a/packages/frontend/src/components/MkNotePreview.vue
+++ b/packages/frontend/src/components/MkNotePreview.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div :class="$style.root">
-	<MkAvatar :class="$style.avatar" :user="user" link preview/>
+	<MkAvatar :class="$style.avatar" :user="user"/>
 	<div :class="$style.main">
 		<div :class="$style.header">
 			<MkUserName :user="user" :nowrap="true"/>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 3fa2eb254e..ee65743574 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -343,7 +343,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
 	margin-right: 4px;
 	position: relative;
 
-	&:before {
+	&::before {
 		position: absolute;
 		transform: rotate(180deg);
 	}
diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue
index f6dc00698c..8559d4b96e 100644
--- a/packages/frontend/src/components/MkPagePreview.vue
+++ b/packages/frontend/src/components/MkPagePreview.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
+<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj">
 	<div v-if="page.eyeCatchingImage" class="thumbnail">
 		<MediaImage
 			:image="page.eyeCatchingImage"
@@ -50,12 +50,29 @@ const props = defineProps<{
 <style lang="scss" scoped>
 .vhpxefrj {
 	display: block;
+	position: relative;
 
 	&:hover {
 		text-decoration: none;
 		color: var(--accent);
 	}
 
+	&:focus-within {
+		outline: none;
+
+		&::after {
+			content: "";
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			border-radius: var(--radius);
+			pointer-events: none;
+			box-shadow: inset 0 0 0 2px var(--focus);
+		}
+	}
+
 	> .thumbnail {
 		& + article {
 			border-radius: 0 0 var(--radius) var(--radius);
diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue
index be0b07612a..8a0c7b1e54 100644
--- a/packages/frontend/src/components/MkPopupMenu.vue
+++ b/packages/frontend/src/components/MkPopupMenu.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed">
-	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
+	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
 </MkModal>
 </template>
 
@@ -19,8 +19,8 @@ defineProps<{
 	items: MenuItem[];
 	align?: 'center' | string;
 	width?: number;
-	viaKeyboard?: boolean;
 	src?: any;
+	returnFocusTo?: HTMLElement | null;
 }>();
 
 const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 0dc1aa0891..d057d197ec 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -570,6 +570,7 @@ function clear() {
 
 function onKeydown(ev: KeyboardEvent) {
 	if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
+
 	if (ev.key === 'Escape') emit('esc');
 }
 
@@ -1083,6 +1084,15 @@ defineExpose({
 	margin: 12px 12px 12px 6px;
 	vertical-align: bottom;
 
+	&:focus-visible {
+		outline: none;
+
+		.submitInner {
+			outline: 2px solid var(--fgOnAccent);
+			outline-offset: -4px;
+		}
+	}
+
 	&:disabled {
 		opacity: 0.7;
 	}
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index ac37cb31bc..d6bca29050 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()">
+<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()">
 	<MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/>
 </MkModal>
 </template>
diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue
index 6676e3bf5b..22fc86723e 100644
--- a/packages/frontend/src/components/MkRadio.vue
+++ b/packages/frontend/src/components/MkRadio.vue
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]"
 	:aria-checked="checked"
 	:aria-disabled="disabled"
+	role="checkbox"
 	@click="toggle"
 >
 	<input
@@ -69,6 +70,11 @@ function toggle(): void {
 		border-color: var(--inputBorderHover) !important;
 	}
 
+	&:focus-within {
+		outline: none;
+		box-shadow: 0 0 0 2px var(--focus);
+	}
+
 	&.checked {
 		background-color: var(--accentedBg) !important;
 		border-color: var(--accentedBg) !important;
@@ -78,7 +84,7 @@ function toggle(): void {
 		> .button {
 			border-color: var(--accent);
 
-			&:after {
+			&::after {
 				background-color: var(--accent);
 				transform: scale(1);
 				opacity: 1;
@@ -104,7 +110,7 @@ function toggle(): void {
 	border-radius: 100%;
 	transition: inherit;
 
-	&:after {
+	&::after {
 		content: '';
 		display: block;
 		position: absolute;
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 358d9b1f4b..0eba8d6a9c 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -6,20 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div>
 	<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
-	<div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show">
+	<div
+		ref="container"
+		tabindex="0"
+		:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]"
+		@focus="focused = true"
+		@blur="focused = false"
+		@mousedown.prevent="show"
+		@keydown.space.enter="show"
+	>
 		<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
 		<select
 			ref="inputEl"
 			v-model="v"
 			v-adaptive-border
+			tabindex="-1"
 			:class="$style.inputCore"
 			:disabled="disabled"
 			:required="required"
 			:readonly="readonly"
 			:placeholder="placeholder"
-			@focus="focused = true"
-			@blur="focused = false"
 			@input="onInput"
+			@mousedown.prevent="() => {}"
+			@keydown.prevent="() => {}"
 		>
 			<slot></slot>
 		</select>
@@ -75,7 +84,7 @@ const height =
 	props.large ? 39 :
 	36;
 
-const focus = () => inputEl.value?.focus();
+const focus = () => container.value?.focus();
 const onInput = (ev) => {
 	changed.value = true;
 };
@@ -126,7 +135,9 @@ onMounted(() => {
 });
 
 function show() {
-	focused.value = true;
+	if (opening.value) return;
+	focus();
+
 	opening.value = true;
 
 	const menu: MenuItem[] = [];
@@ -173,8 +184,6 @@ function show() {
 		onClosing: () => {
 			opening.value = false;
 		},
-	}).then(() => {
-		focused.value = false;
 	});
 }
 </script>
@@ -225,6 +234,10 @@ function show() {
 		}
 	}
 
+	&:focus {
+		outline: none;
+	}
+
 	&:hover {
 		> .inputCore {
 			border-color: var(--inputBorderHover) !important;
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index 3023f63e5d..1a880170be 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -10,15 +10,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 		<div class="items">
 			<template v-for="(item, i) in group.items">
-				<a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+				<a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }">
 					<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
 					<span class="text">{{ item.text }}</span>
 				</a>
-				<button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)">
+				<button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)">
 					<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
 					<span class="text">{{ item.text }}</span>
 				</button>
-				<MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+				<MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }">
 					<span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span>
 					<span class="text">{{ item.text }}</span>
 				</MkA>
@@ -67,6 +67,10 @@ defineProps<{
 					background: var(--panelHighlight);
 				}
 
+				&:focus-visible {
+					outline-offset: -2px;
+				}
+
 				&.active {
 					color: var(--accent);
 					background: var(--accentedBg);
diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue
index 721ac357f4..a0994d9cc9 100644
--- a/packages/frontend/src/components/MkSwitch.vue
+++ b/packages/frontend/src/components/MkSwitch.vue
@@ -10,9 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 		type="checkbox"
 		:disabled="disabled"
 		:class="$style.input"
-		@keydown.enter="toggle"
+		@click="toggle"
 	>
-	<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
+	<XButton :class="$style.toggle" :checked="checked" :disabled="disabled" @toggle="toggle"/>
 	<span v-if="!noBody" :class="$style.body">
 		<!-- TODO: 無名slotの方は廃止 -->
 		<span :class="$style.label">
@@ -75,7 +75,13 @@ const toggle = () => {
 	height: 0;
 	opacity: 0;
 	margin: 0;
+
+	&:focus-visible ~ .toggle {
+		outline: 2px solid var(--focus);
+		outline-offset: 2px;
+	}
 }
+
 .body {
 	margin-left: 12px;
 	margin-top: 2px;
diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
index e1d88b5e5c..27483cc7c2 100644
--- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue
@@ -105,7 +105,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
 	font-weight: bold;
 	text-align: left;
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		width: calc(100% - 38px);
diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
index 7ae48dcd15..d8d4b5aab7 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue
@@ -115,7 +115,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
 	font-weight: bold;
 	text-align: left;
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		width: calc(100% - 38px);
diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue
index 57f26e86a7..6f2930ebc9 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue
@@ -56,7 +56,7 @@ import { i18n } from '@/i18n.js';
 	font-weight: bold;
 	text-align: left;
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		width: calc(100% - 38px);
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
index 5ecd41bfdf..75066bbc32 100644
--- a/packages/frontend/src/components/MkVisibilityPicker.vue
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')">
+<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
 	<div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }">
 		<div :class="[$style.label, $style.item]">
 			{{ i18n.ts.visibility }}
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 89993e1b8e..b12dc8cb31 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -8,7 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div ref="headerEl">
 		<slot name="header"></slot>
 	</div>
-	<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
+	<div
+		ref="bodyEl"
+		:data-sticky-container-header-height="headerHeight"
+		:data-sticky-container-footer-height="footerHeight"
+	>
 		<slot></slot>
 	</div>
 	<div ref="footerEl">
diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts
index 0a7d136f18..0e5c7ede24 100644
--- a/packages/frontend/src/directives/hotkey.ts
+++ b/packages/frontend/src/directives/hotkey.ts
@@ -13,9 +13,9 @@ export default {
 		el._keyHandler = makeHotkey(binding.value);
 
 		if (el._hotkey_global) {
-			document.addEventListener('keydown', el._keyHandler);
+			document.addEventListener('keydown', el._keyHandler, { passive: false });
 		} else {
-			el.addEventListener('keydown', el._keyHandler);
+			el.addEventListener('keydown', el._keyHandler, { passive: false });
 		}
 	},
 
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 560f692acf..17ee6e294a 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -5,7 +5,7 @@
 
 // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
 
-import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
+import { Component, markRaw, Ref, ref, defineAsyncComponent, nextTick } from 'vue';
 import { EventEmitter } from 'eventemitter3';
 import * as Misskey from 'misskey-js';
 import type { ComponentProps as CP } from 'vue-component-type-helpers';
@@ -24,6 +24,8 @@ import MkContextMenu from '@/components/MkContextMenu.vue';
 import { MenuItem } from '@/types/menu.js';
 import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
+import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
+import { focusParent } from './scripts/focus.js';
 
 export const openingWindowsCount = ref(0);
 
@@ -622,31 +624,33 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
 export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
 	align?: string;
 	width?: number;
-	viaKeyboard?: boolean;
 	onClosing?: () => void;
 }): Promise<void> {
-	return new Promise(resolve => {
+	let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement);
+	return new Promise(resolve => nextTick(() => {
 		const { dispose } = popup(MkPopupMenu, {
 			items,
 			src,
 			width: options?.width,
 			align: options?.align,
-			viaKeyboard: options?.viaKeyboard,
+			returnFocusTo,
 		}, {
 			closed: () => {
 				resolve();
 				dispose();
+				returnFocusTo = null;
 			},
 			closing: () => {
-				if (options?.onClosing) options.onClosing();
+				options?.onClosing?.();
 			},
 		});
-	});
+	}));
 }
 
 export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
+	let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement);
 	ev.preventDefault();
-	return new Promise(resolve => {
+	return new Promise(resolve => nextTick(() => {
 		const { dispose } = popup(MkContextMenu, {
 			items,
 			ev,
@@ -654,14 +658,19 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
 			closed: () => {
 				resolve();
 				dispose();
+
+				// MkModalを通していないのでここでフォーカスを戻す処理を行う
+				if (returnFocusTo != null) {
+					focusParent(returnFocusTo, true, false);
+					returnFocusTo = null;
+				}
 			},
 		});
-	});
+	}));
 }
 
 export function post(props: Record<string, any> = {}): Promise<void> {
 	showMovedDialog();
-
 	return new Promise(resolve => {
 		// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
 		// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index 7a8786d415..a774412f83 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -234,6 +234,7 @@ onMounted(async () => {
 			background-color: var(--accentedBg);
 			color: var(--accent);
 			text-decoration: none;
+			outline: none;
 		}
 
 		&.danger {
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index afd6df1ad9..b52f4decaa 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader/></template>
 	<MkSpacer :contentMax="800">
 		<div class="_gaps">
-			<div class="_panel">
+			<div class="_panel" :class="$style.link">
 				<MkA to="/bubble-game">
 					<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
 				</MkA>
 			</div>
-			<div class="_panel">
+			<div class="_panel" :class="$style.link">
 				<MkA to="/reversi">
 					<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
 				</MkA>
@@ -32,3 +32,10 @@ definePageMetadata(() => ({
 	icon: 'ti ti-device-gamepad',
 }));
 </script>
+
+<style module>
+.link:focus-within {
+	outline: 2px solid var(--focus);
+	outline-offset: -2px;
+}
+</style>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index e73d032000..e2f04eb764 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -286,6 +286,7 @@ definePageMetadata(() => ({
 		background-color: var(--accentedBg);
 		color: var(--accent);
 		text-decoration: none;
+		outline: none;
 	}
 }
 
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 60bf9b4d3d..a328933686 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -342,6 +342,7 @@ definePageMetadata(() => ({
 	&:hover, &:focus {
 		opacity: .7;
 	}
+
 	&:active {
 		cursor: pointer;
 	}
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
index 0a4bd4b826..7d192bcbea 100644
--- a/packages/frontend/src/pages/settings/theme.vue
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -213,12 +213,18 @@ definePageMetadata(() => ({
 			}
 		}
 
+		.dn:focus-visible ~ .toggle {
+			outline: 2px solid var(--focus);
+			outline-offset: 2px;
+		}
+
 		.toggle {
 			cursor: pointer;
 			display: inline-block;
 			position: relative;
 			width: 90px;
 			height: 50px;
+			margin: 4px; // focus用のアウトライン
 			background-color: #83D8FF;
 			border-radius: 90px - 6;
 			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts
new file mode 100644
index 0000000000..734c73652f
--- /dev/null
+++ b/packages/frontend/src/scripts/focus-trap.ts
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
+
+const focusTrapElements = new Set<HTMLElement>();
+const ignoreElements = [
+	'script',
+	'style',
+];
+
+function containsFocusTrappedElements(el: HTMLElement): boolean {
+	return Array.from(focusTrapElements).some((focusTrapElement) => {
+		return el.contains(focusTrapElement);
+	});
+}
+
+function releaseFocusTrap(el: HTMLElement): void {
+	focusTrapElements.delete(el);
+	if (el.parentElement != null && el !== document.body) {
+		el.parentElement.childNodes.forEach((siblingNode) => {
+			const siblingEl = getHTMLElementOrNull(siblingNode);
+			if (!siblingEl) return;
+			if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) {
+				siblingEl.inert = false;
+			} else if (
+				focusTrapElements.size > 0 &&
+				!containsFocusTrappedElements(siblingEl) &&
+				!focusTrapElements.has(siblingEl) &&
+				!ignoreElements.includes(siblingEl.tagName.toLowerCase())
+			) {
+				siblingEl.inert = true;
+			} else {
+				siblingEl.inert = false;
+			}
+		});
+		releaseFocusTrap(el.parentElement);
+	}
+}
+
+export function focusTrap(el: HTMLElement, parent: true): void;
+export function focusTrap(el: HTMLElement, parent?: false): { release: () => void; };
+export function focusTrap(el: HTMLElement, parent = false): { release: () => void; } | void {
+	if (el.parentElement != null && el !== document.body) {
+		el.parentElement.childNodes.forEach((siblingNode) => {
+			const siblingEl = getHTMLElementOrNull(siblingNode);
+			if (!siblingEl) return;
+			if (siblingEl !== el && !ignoreElements.includes(siblingEl.tagName.toLowerCase())) {
+				siblingEl.inert = true;
+			}
+		});
+		focusTrap(el.parentElement, true);
+	}
+
+	if (!parent) {
+		focusTrapElements.add(el);
+
+		return {
+			release: () => {
+				releaseFocusTrap(el);
+			},
+		};
+	}
+}
diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts
index ea6ee61c88..eb2da5ad86 100644
--- a/packages/frontend/src/scripts/focus.ts
+++ b/packages/frontend/src/scripts/focus.ts
@@ -3,30 +3,78 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-export function focusPrev(el: Element | null, self = false, scroll = true) {
-	if (el == null) return;
-	if (!self) el = el.previousElementSibling;
-	if (el) {
-		if (el.hasAttribute('tabindex')) {
-			(el as HTMLElement).focus({
-				preventScroll: !scroll,
-			});
-		} else {
-			focusPrev(el.previousElementSibling, true);
-		}
+import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js';
+import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
+
+type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;
+
+export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => {
+	if (input == null || !(input instanceof HTMLElement)) return false;
+
+	if (input.tabIndex < 0) return false;
+	if ('disabled' in input && input.disabled === true) return false;
+	if ('readonly' in input && input.readonly === true) return false;
+
+	if (!input.ownerDocument.contains(input)) return false;
+
+	const style = window.getComputedStyle(input);
+	if (style.display === 'none') return false;
+	if (style.visibility === 'hidden') return false;
+	if (style.opacity === '0') return false;
+	if (style.pointerEvents === 'none') return false;
+
+	return true;
+};
+
+export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getElementOrNull(input)?.previousElementSibling;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusPrev(element, false, scroll);
 	}
-}
-
-export function focusNext(el: Element | null, self = false, scroll = true) {
-	if (el == null) return;
-	if (!self) el = el.nextElementSibling;
-	if (el) {
-		if (el.hasAttribute('tabindex')) {
-			(el as HTMLElement).focus({
-				preventScroll: !scroll,
-			});
-		} else {
-			focusPrev(el.nextElementSibling, true);
+};
+
+export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getElementOrNull(input)?.nextElementSibling;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusNext(element, false, scroll);
+	}
+};
+
+export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
+	const element = self ? input : getNodeOrNull(input)?.parentElement;
+	if (element == null) return;
+	if (isFocusable(element)) {
+		focusOrScroll(element, scroll);
+	} else {
+		focusParent(element, false, scroll);
+	}
+};
+
+const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
+	if (scroll) {
+		const scrollContainer = getScrollContainer(element) ?? document.documentElement;
+		const scrollContainerTop = getScrollPosition(scrollContainer);
+		const stickyTop = getStickyTop(element, scrollContainer);
+		const stickyBottom = getStickyBottom(element, scrollContainer);
+		const top = element.getBoundingClientRect().top;
+		const bottom = element.getBoundingClientRect().bottom;
+
+		let scrollTo = scrollContainerTop;
+		if (top < stickyTop) {
+			scrollTo += top - stickyTop;
+		} else if (bottom > window.innerHeight - stickyBottom) {
+			scrollTo += bottom - window.innerHeight + stickyBottom;
 		}
+		scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' });
+	}
+
+	if (document.activeElement !== element) {
+		element.focus({ preventScroll: true });
 	}
-}
+};
diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/scripts/get-dom-node-or-null.ts
new file mode 100644
index 0000000000..fbf54675fd
--- /dev/null
+++ b/packages/frontend/src/scripts/get-dom-node-or-null.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const getNodeOrNull = (input: unknown): Node | null => {
+	if (input instanceof Node) return input;
+	return null;
+};
+
+export const getElementOrNull = (input: unknown): Element | null => {
+	if (input instanceof Element) return input;
+	return null;
+};
+
+export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => {
+	if (input instanceof HTMLElement) return input;
+	return null;
+};
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
index fd79baa604..ff3cbe98ac 100644
--- a/packages/frontend/src/scripts/hotkey.ts
+++ b/packages/frontend/src/scripts/hotkey.ts
@@ -2,6 +2,7 @@
  * SPDX-FileCopyrightText: syuilo and misskey-project
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js";
 
 //#region types
 export type Keymap = Record<string, CallbackFunction | CallbackObject>;
@@ -30,8 +31,8 @@ type Action = {
 //#region consts
 const KEY_ALIASES = {
 	'esc': 'Escape',
-	'enter': ['Enter', 'NumpadEnter'],
-	'space': [' ', 'Spacebar'],
+	'enter': 'Enter',
+	'space': ' ',
 	'up': 'ArrowUp',
 	'down': 'ArrowDown',
 	'left': 'ArrowLeft',
@@ -44,6 +45,10 @@ const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
 const IGNORE_ELEMENTS = ['input', 'textarea'];
 //#endregion
 
+//#region store
+let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
+//#endregion
+
 //#region impl
 export const makeHotkey = (keymap: Keymap) => {
 	const actions = parseKeymap(keymap);
@@ -51,13 +56,14 @@ export const makeHotkey = (keymap: Keymap) => {
 		if ('pswp' in window && window.pswp != null) return;
 		if (document.activeElement != null) {
 			if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
-			if ((document.activeElement as HTMLElement).isContentEditable) return;
+			if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
 		}
-		for (const { patterns, callback, options } of actions) {
-			if (matchPatterns(ev, patterns, options)) {
+		for (const action of actions) {
+			if (matchPatterns(ev, action)) {
 				ev.preventDefault();
 				ev.stopPropagation();
-				callback(ev);
+				action.callback(ev);
+				storePattern(ev, action.callback);
 			}
 		}
 	};
@@ -102,10 +108,21 @@ const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
 	return { ...defaultOptions } as const satisfies Action['options'];
 };
 
-const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => {
+const matchPatterns = (ev: KeyboardEvent, action: Action) => {
+	const { patterns, options, callback } = action;
 	if (ev.repeat && !options.allowRepeat) return false;
 	const key = ev.key.toLowerCase();
 	return patterns.some(({ which, ctrl, shift, alt }) => {
+		if (
+			latestHotkey != null &&
+			latestHotkey.which.includes(key) &&
+			latestHotkey.ctrl === ctrl &&
+			latestHotkey.alt === alt &&
+			latestHotkey.shift === shift &&
+			latestHotkey.callback === callback
+		) {
+			return false;
+		}
 		if (!which.includes(key)) return false;
 		if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
 		if (alt !== ev.altKey) return false;
@@ -114,6 +131,26 @@ const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options:
 	});
 };
 
+let lastHotKeyStoreTimer: number | null = null;
+
+const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
+	if (lastHotKeyStoreTimer != null) {
+		clearTimeout(lastHotKeyStoreTimer);
+	}
+
+	latestHotkey = {
+		which: [ev.key.toLowerCase()],
+		ctrl: ev.ctrlKey || ev.metaKey,
+		alt: ev.altKey,
+		shift: ev.shiftKey,
+		callback,
+	};
+
+	lastHotKeyStoreTimer = window.setTimeout(() => {
+		latestHotkey = null;
+	}, 500);
+};
+
 const parseKeyCode = (input?: string | null) => {
 	if (input == null) return [];
 	const raw = getValueByKey(KEY_ALIASES, input);
diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts
index 8edb6fca05..f0274034b5 100644
--- a/packages/frontend/src/scripts/scroll.ts
+++ b/packages/frontend/src/scripts/scroll.ts
@@ -23,6 +23,14 @@ export function getStickyTop(el: HTMLElement, container: HTMLElement | null = nu
 	return getStickyTop(el.parentElement, container, newTop);
 }
 
+export function getStickyBottom(el: HTMLElement, container: HTMLElement | null = null, bottom = 0) {
+	if (!el.parentElement) return bottom;
+	const data = el.dataset.stickyContainerFooterHeight;
+	const newBottom = data ? Number(data) + bottom : bottom;
+	if (el === container) return newBottom;
+	return getStickyBottom(el.parentElement, container, newBottom);
+}
+
 export function getScrollPosition(el: HTMLElement | null): number {
 	const container = getScrollContainer(el);
 	return container == null ? window.scrollY : container.scrollTop;
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 250a2616a7..2feb79ef81 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -113,6 +113,10 @@ a {
 	-webkit-tap-highlight-color: transparent;
 	-webkit-touch-callout: none;
 
+	&:focus-visible {
+		outline-offset: 2px;
+	}
+
 	&:hover {
 		text-decoration: underline;
 	}
@@ -143,12 +147,21 @@ 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 {
+	&::before {
 		font-size: 128%;
 	}
 }
@@ -230,10 +243,6 @@ rt {
 	line-height: inherit;
 	max-width: 100%;
 
-	&:focus-visible {
-		outline: none;
-	}
-
 	&:disabled {
 		opacity: 0.5;
 		cursor: default;
@@ -270,13 +279,17 @@ rt {
 
 ._help {
 	color: var(--accent);
-	cursor: help
+	cursor: help;
 }
 
 ._textButton {
 	@extend ._button;
 	color: var(--accent);
 
+	&:focus-visible {
+		outline-offset: 2px;
+	}
+
 	&:not(:disabled):hover {
 		text-decoration: underline;
 	}
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 822b552837..d7df2d10f9 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -227,7 +227,7 @@ if ($i) {
 	right: 15px;
 	pointer-events: none;
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		width: 18px;
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 699aa1e1c8..87e9e45e63 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -139,7 +139,7 @@ function more() {
 	font-weight: bold;
 	text-align: left;
 
-	&:before {
+	&::before {
 		content: "";
 		display: block;
 		width: calc(100% - 38px);
@@ -155,7 +155,7 @@ function more() {
 	}
 
 	&:hover, &.active {
-		&:before {
+		&::before {
 			background: var(--accentLighten);
 		}
 	}
@@ -226,7 +226,7 @@ function more() {
 	}
 
 	&:hover, &.active {
-		&:before {
+		&::before {
 			content: "";
 			display: block;
 			width: calc(100% - 24px);
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index b029533f28..8307da0d42 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -166,6 +166,15 @@ function more(ev: MouseEvent) {
 		display: block;
 		text-align: center;
 		width: 100%;
+
+		&:focus-visible {
+			outline: none;
+
+			> .instanceIcon {
+				outline: 2px solid var(--focus);
+				outline-offset: 2px;
+			}
+		}
 	}
 
 	.instanceIcon {
@@ -192,7 +201,7 @@ function more(ev: MouseEvent) {
 		font-weight: bold;
 		text-align: left;
 
-		&:before {
+		&::before {
 			content: "";
 			display: block;
 			width: calc(100% - 38px);
@@ -207,8 +216,17 @@ function more(ev: MouseEvent) {
 			background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
 		}
 
+		&:focus-visible {
+			outline: none;
+
+			&::before {
+				outline: 2px solid var(--fgOnAccent);
+				outline-offset: -4px;
+			}
+		}
+
 		&:hover, &.active {
-			&:before {
+			&::before {
 				background: var(--accentLighten);
 			}
 		}
@@ -234,6 +252,14 @@ function more(ev: MouseEvent) {
 		text-align: left;
 		box-sizing: border-box;
 		overflow: clip;
+
+		&:focus-visible {
+			outline: none;
+
+			> .avatar {
+				box-shadow: 0 0 0 4px var(--focus);
+			}
+		}
 	}
 
 	.avatar {
@@ -282,10 +308,19 @@ function more(ev: MouseEvent) {
 			color: var(--navActive);
 		}
 
-		&:hover, &.active {
+		&:focus-visible {
+			outline: none;
+
+			&::before {
+				outline: 2px solid var(--focus);
+				outline-offset: -2px;
+			}
+		}
+
+		&:hover, &.active, &:focus {
 			color: var(--accent);
 
-			&:before {
+			&::before {
 				content: "";
 				display: block;
 				width: calc(100% - 34px);
@@ -352,6 +387,15 @@ function more(ev: MouseEvent) {
 		display: block;
 		text-align: center;
 		width: 100%;
+
+		&:focus-visible {
+			outline: none;
+
+			> .instanceIcon {
+				outline: 2px solid var(--focus);
+				outline-offset: 2px;
+			}
+		}
 	}
 
 	.instanceIcon {
@@ -376,7 +420,7 @@ function more(ev: MouseEvent) {
 		height: 52px;
 		text-align: center;
 
-		&:before {
+		&::before {
 			content: "";
 			display: block;
 			position: absolute;
@@ -391,8 +435,17 @@ function more(ev: MouseEvent) {
 			background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
 		}
 
+		&:focus-visible {
+			outline: none;
+
+			&::before {
+				outline: 2px solid var(--fgOnAccent);
+				outline-offset: -4px;
+			}
+		}
+
 		&:hover, &.active {
-			&:before {
+			&::before {
 				background: var(--accentLighten);
 			}
 		}
@@ -413,6 +466,14 @@ function more(ev: MouseEvent) {
 		padding: 20px 0;
 		width: 100%;
 		overflow: clip;
+
+		&:focus-visible {
+			outline: none;
+
+			> .avatar {
+				box-shadow: 0 0 0 4px var(--focus);
+			}
+		}
 	}
 
 	.avatar {
@@ -442,11 +503,20 @@ function more(ev: MouseEvent) {
 		width: 100%;
 		text-align: center;
 
-		&:hover, &.active {
+		&:focus-visible {
+			outline: none;
+
+			&::before {
+				outline: 2px solid var(--focus);
+				outline-offset: -2px;
+			}
+		}
+
+		&:hover, &.active, &:focus {
 			text-decoration: none;
 			color: var(--accent);
 
-			&:before {
+			&::before {
 				content: "";
 				display: block;
 				height: 100%;
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index 07845bacbb..e96402d13b 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -271,7 +271,7 @@ function onDrop(ev) {
 	border-radius: 10px;
 
 	&.draghover {
-		&:after {
+		&::after {
 			content: "";
 			display: block;
 			position: absolute;
@@ -285,7 +285,7 @@ function onDrop(ev) {
 	}
 
 	&.dragging {
-		&:after {
+		&::after {
 			content: "";
 			display: block;
 			position: absolute;
diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue
index c688e8a0b1..6ece33eff3 100644
--- a/packages/frontend/src/widgets/WidgetCalendar.vue
+++ b/packages/frontend/src/widgets/WidgetCalendar.vue
@@ -121,7 +121,7 @@ defineExpose<WidgetComponentExpose>({
 .root {
 	padding: 16px 0;
 
-	&:after {
+	&::after {
 		content: "";
 		display: block;
 		clear: both;
-- 
cgit v1.2.3-freya

