diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83d013ce9..005b01159 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,7 +19,7 @@ You should also include the user name that made the change.
 - Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator
 	- You may have to `yarn run clean-all`, `sudo corepack enable` and `yarn set version berry` before running `yarn install` if you're still on yarn classic
 - 新たに動的なPagesを作ることはできなくなりました
-	- 代わりに今後AiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能の実装を予定しています。
+	- 代わりにAiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能が実装されています。
 - AiScriptが0.12.0にアップデートされました
 	- 0.12.0の変更点についてはこちら https://github.com/syuilo/aiscript/blob/master/CHANGELOG.md#0120
 	- 0.12.0未満のプラグインは読み込むことはできません
@@ -33,12 +33,13 @@ You should also include the user name that made the change.
 - API: `instance`エンティティに`latestStatus`、`lastCommunicatedAt`、`latestRequestSentAt`プロパティが含まれなくなりました
 
 ### Improvements
-- Push notification of Antenna note @tamaina
-- AVIF support @tamaina
-- Add Cloudflare Turnstile CAPTCHA support @CyberRex0
+- Misskey Play @syuilo
 - Introduce retention-rate aggregation @syuilo
 - Make possible to export favorited notes @syuilo
 - Add per user pv chart @syuilo
+- Push notification of Antenna note @tamaina
+- AVIF support @tamaina
+- Add Cloudflare Turnstile CAPTCHA support @CyberRex0
 - Server: signToActivityPubGet is set to true by default @syuilo
 - Server: improve syslog performance @syuilo
 - Server: improve note scoring for featured notes @CyberRex0
@@ -47,6 +48,7 @@ You should also include the user name that made the change.
 - Server: delete outdated notes of antenna regularly to improve db performance @syuilo
 - Server: improve activitypub deliver performance @syuilo
 - Client: use tabler-icons instead of fontawesome to better design @syuilo
+- Client: Add AiScript App widget
 - Client: Add new gabber kick sounds (thanks for noizenecio)
 - Client: Add link to user RSS feed in profile menu @ssmucny
 - Client: Compress non-animated PNG files @saschanaz
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d6a551819..32bafcd66 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -916,6 +916,10 @@ loggedInAsBot: "Botアカウントでログイン中"
 tools: "ツール"
 cannotLoad: "読み込めません"
 numberOfProfileView: "プロフィール表示回数"
+like: "いいね!"
+unlike: "いいねを解除"
+numberOfLikes: "いいね数"
+show: "表示"
 
 _sensitiveMediaDetection:
   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
@@ -1348,6 +1352,7 @@ _widgets:
   jobQueue: "ジョブキュー"
   serverMetric: "サーバーメトリクス"
   aiscript: "AiScriptコンソール"
+  aiscriptApp: "AiScript App"
   aichan: "藍"
   userList: "ユーザーリスト"
   _userList:
@@ -1463,6 +1468,22 @@ _timelines:
   social: "ソーシャル"
   global: "グローバル"
 
+_play:
+  new: "Playの作成"
+  edit: "Playの編集"
+  created: "Playを作成しました"
+  updated: "Playを更新しました"
+  deleted: "Playを削除しました"
+  pageSetting: "Play設定"
+  editThisPage: "このPlayを編集"
+  viewSource: "ソースを表示"
+  my: "自分のPlay"
+  liked: "いいねしたPlay"
+  featured: "人気"
+  title: "タイトル"
+  script: "スクリプト"
+  summary: "説明"
+
 _pages:
   newPage: "ページの作成"
   editPage: "ページの編集"
diff --git a/packages/backend/migration/1672822262496-Flash.js b/packages/backend/migration/1672822262496-Flash.js
new file mode 100644
index 000000000..6c2338fab
--- /dev/null
+++ b/packages/backend/migration/1672822262496-Flash.js
@@ -0,0 +1,29 @@
+export class Flash1672822262496 {
+    name = 'Flash1672822262496'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TABLE "flash" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "summary" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "script" character varying(16384) NOT NULL, "permissions" character varying(256) array NOT NULL DEFAULT '{}', "likedCount" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_0c01a2c1c5f2266942dd1b3fdbc" PRIMARY KEY ("id")); COMMENT ON COLUMN "flash"."createdAt" IS 'The created date of the Flash.'; COMMENT ON COLUMN "flash"."updatedAt" IS 'The updated date of the Flash.'; COMMENT ON COLUMN "flash"."userId" IS 'The ID of author.'`);
+        await queryRunner.query(`CREATE INDEX "IDX_149d2e44785707548c82999b01" ON "flash" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_3aa8ea9a8f15214ad91638c0a7" ON "flash" ("updatedAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_9b88250fc2fd009b8f1b5623ed" ON "flash" ("userId") `);
+        await queryRunner.query(`CREATE TABLE "flash_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "flashId" character varying(32) NOT NULL, CONSTRAINT "PK_d110109ee310588d63d6183b233" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_60c4af1c19a7a75f1592f93b28" ON "flash_like" ("userId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_cfbfeeccb0cbedcd660b17eb07" ON "flash_like" ("userId", "flashId") `);
+        await queryRunner.query(`ALTER TABLE "flash" ADD CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a" FOREIGN KEY ("flashId") REFERENCES "flash"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a"`);
+        await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287"`);
+        await queryRunner.query(`ALTER TABLE "flash" DROP CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_cfbfeeccb0cbedcd660b17eb07"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_60c4af1c19a7a75f1592f93b28"`);
+        await queryRunner.query(`DROP TABLE "flash_like"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_9b88250fc2fd009b8f1b5623ed"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_3aa8ea9a8f15214ad91638c0a7"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_149d2e44785707548c82999b01"`);
+        await queryRunner.query(`DROP TABLE "flash"`);
+    }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 7c6d12abf..2f17fa389 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -95,6 +95,8 @@ import { UserEntityService } from './entities/UserEntityService.js';
 import { UserGroupEntityService } from './entities/UserGroupEntityService.js';
 import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js';
 import { UserListEntityService } from './entities/UserListEntityService.js';
+import { FlashEntityService } from './entities/FlashEntityService.js';
+import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
 import { ApAudienceService } from './activitypub/ApAudienceService.js';
 import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
 import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@@ -216,6 +218,8 @@ const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting
 const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService };
 const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService };
 const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService };
+const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
+const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
 
 const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
 const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -338,6 +342,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		UserGroupEntityService,
 		UserGroupInvitationEntityService,
 		UserListEntityService,
+		FlashEntityService,
+		FlashLikeEntityService,
 		ApAudienceService,
 		ApDbResolverService,
 		ApDeliverManagerService,
@@ -455,6 +461,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$UserGroupEntityService,
 		$UserGroupInvitationEntityService,
 		$UserListEntityService,
+		$FlashEntityService,
+		$FlashLikeEntityService,
 		$ApAudienceService,
 		$ApDbResolverService,
 		$ApDeliverManagerService,
@@ -572,6 +580,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		UserGroupEntityService,
 		UserGroupInvitationEntityService,
 		UserListEntityService,
+		FlashEntityService,
+		FlashLikeEntityService,
 		ApAudienceService,
 		ApDbResolverService,
 		ApDeliverManagerService,
@@ -688,6 +698,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$UserGroupEntityService,
 		$UserGroupInvitationEntityService,
 		$UserListEntityService,
+		$FlashEntityService,
+		$FlashLikeEntityService,
 		$ApAudienceService,
 		$ApDbResolverService,
 		$ApDeliverManagerService,
diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts
new file mode 100644
index 000000000..61bd18c04
--- /dev/null
+++ b/packages/backend/src/core/entities/FlashEntityService.ts
@@ -0,0 +1,55 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/schema.js';
+import type { } from '@/models/entities/Blocking.js';
+import type { User } from '@/models/entities/User.js';
+import type { Flash } from '@/models/entities/Flash.js';
+import { bindThis } from '@/decorators.js';
+import { UserEntityService } from './UserEntityService.js';
+
+@Injectable()
+export class FlashEntityService {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		@Inject(DI.flashLikesRepository)
+		private flashLikesRepository: FlashLikesRepository,
+
+		private userEntityService: UserEntityService,
+	) {
+	}
+
+	@bindThis
+	public async pack(
+		src: Flash['id'] | Flash,
+		me?: { id: User['id'] } | null | undefined,
+	): Promise<Packed<'Flash'>> {
+		const meId = me ? me.id : null;
+		const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
+
+		return await awaitAll({
+			id: flash.id,
+			createdAt: flash.createdAt.toISOString(),
+			updatedAt: flash.updatedAt.toISOString(),
+			userId: flash.userId,
+			user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { detail: true } すると無限ループするので注意
+			title: flash.title,
+			summary: flash.summary,
+			script: flash.script,
+			likedCount: flash.likedCount,
+			isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined,
+		});
+	}
+
+	@bindThis
+	public packMany(
+		flashs: Flash[],
+		me?: { id: User['id'] } | null | undefined,
+	) {
+		return Promise.all(flashs.map(x => this.pack(x, me)));
+	}
+}
+
diff --git a/packages/backend/src/core/entities/FlashLikeEntityService.ts b/packages/backend/src/core/entities/FlashLikeEntityService.ts
new file mode 100644
index 000000000..dcf12d53e
--- /dev/null
+++ b/packages/backend/src/core/entities/FlashLikeEntityService.ts
@@ -0,0 +1,44 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { FlashLikesRepository } from '@/models/index.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/schema.js';
+import type { } from '@/models/entities/Blocking.js';
+import type { User } from '@/models/entities/User.js';
+import type { FlashLike } from '@/models/entities/FlashLike.js';
+import { bindThis } from '@/decorators.js';
+import { UserEntityService } from './UserEntityService.js';
+import { FlashEntityService } from './FlashEntityService.js';
+
+@Injectable()
+export class FlashLikeEntityService {
+	constructor(
+		@Inject(DI.flashLikesRepository)
+		private flashLikesRepository: FlashLikesRepository,
+
+		private flashEntityService: FlashEntityService,
+	) {
+	}
+
+	@bindThis
+	public async pack(
+		src: FlashLike['id'] | FlashLike,
+		me?: { id: User['id'] } | null | undefined,
+	) {
+		const like = typeof src === 'object' ? src : await this.flashLikesRepository.findOneByOrFail({ id: src });
+
+		return {
+			id: like.id,
+			flash: await this.flashEntityService.pack(like.flash ?? like.flashId, me),
+		};
+	}
+
+	@bindThis
+	public packMany(
+		likes: any[],
+		me: { id: User['id'] },
+	) {
+		return Promise.all(likes.map(x => this.pack(x, me)));
+	}
+}
+
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index d2a361405..9719d773c 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -69,5 +69,7 @@ export const DI = {
 	adsRepository: Symbol('adsRepository'),
 	passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
 	retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
+	flashsRepository: Symbol('flashsRepository'),
+	flashLikesRepository: Symbol('flashLikesRepository'),
 	//#endregion
 };
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index e22f0517c..a5d5a6393 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -1,6 +1,6 @@
 import { Module } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
-import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation } from './index.js';
+import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash } from './index.js';
 import type { DataSource } from 'typeorm';
 import type { Provider } from '@nestjs/common';
 
@@ -388,6 +388,18 @@ const $retentionAggregationsRepository: Provider = {
 	inject: [DI.db],
 };
 
+const $flashsRepository: Provider = {
+	provide: DI.flashsRepository,
+	useFactory: (db: DataSource) => db.getRepository(Flash),
+	inject: [DI.db],
+};
+
+const $flashLikesRepository: Provider = {
+	provide: DI.flashLikesRepository,
+	useFactory: (db: DataSource) => db.getRepository(FlashLike),
+	inject: [DI.db],
+};
+
 @Module({
 	imports: [
 	],
@@ -456,6 +468,8 @@ const $retentionAggregationsRepository: Provider = {
 		$adsRepository,
 		$passwordResetRequestsRepository,
 		$retentionAggregationsRepository,
+		$flashsRepository,
+		$flashLikesRepository,
 	],
 	exports: [
 		$usersRepository,
@@ -522,6 +536,8 @@ const $retentionAggregationsRepository: Provider = {
 		$adsRepository,
 		$passwordResetRequestsRepository,
 		$retentionAggregationsRepository,
+		$flashsRepository,
+		$flashLikesRepository,
 	],
 })
 export class RepositoryModule {}
diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts
new file mode 100644
index 000000000..d9a6ac987
--- /dev/null
+++ b/packages/backend/src/models/entities/Flash.ts
@@ -0,0 +1,60 @@
+import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+import { DriveFile } from './DriveFile.js';
+
+@Entity()
+export class Flash {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the Flash.',
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The updated date of the Flash.',
+	})
+	public updatedAt: Date;
+
+	@Column('varchar', {
+		length: 256,
+	})
+	public title: string;
+
+	@Column('varchar', {
+		length: 1024,
+	})
+	public summary: string;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The ID of author.',
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column('varchar', {
+		length: 16384,
+	})
+	public script: string;
+
+	@Column('varchar', {
+		length: 256, array: true, default: '{}',
+	})
+	public permissions: string[];
+
+	@Column('integer', {
+		default: 0,
+	})
+	public likedCount: number;
+}
diff --git a/packages/backend/src/models/entities/FlashLike.ts b/packages/backend/src/models/entities/FlashLike.ts
new file mode 100644
index 000000000..81d39191c
--- /dev/null
+++ b/packages/backend/src/models/entities/FlashLike.ts
@@ -0,0 +1,33 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+import { Flash } from './Flash.js';
+
+@Entity()
+@Index(['userId', 'flashId'], { unique: true })
+export class FlashLike {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone')
+	public createdAt: Date;
+
+	@Index()
+	@Column(id())
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column(id())
+	public flashId: Flash['id'];
+
+	@ManyToOne(type => Flash, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public flash: Flash | null;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index ca7a7c9e5..b13247574 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -62,6 +62,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
 import { Webhook } from '@/models/entities/Webhook.js';
 import { Channel } from '@/models/entities/Channel.js';
 import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
+import { Flash } from '@/models/entities/Flash.js';
+import { FlashLike } from '@/models/entities/FlashLike.js';
 import type { Repository } from 'typeorm';
 
 export {
@@ -129,6 +131,8 @@ export {
 	Webhook,
 	Channel,
 	RetentionAggregation,
+	Flash,
+	FlashLike,
 };
 
 export type AbuseUserReportsRepository = Repository<AbuseUserReport>;
@@ -195,3 +199,5 @@ export type UserSecurityKeysRepository = Repository<UserSecurityKey>;
 export type WebhooksRepository = Repository<Webhook>;
 export type ChannelsRepository = Repository<Channel>;
 export type RetentionAggregationsRepository = Repository<RetentionAggregation>;
+export type FlashsRepository = Repository<Flash>;
+export type FlashLikesRepository = Repository<FlashLike>;
diff --git a/packages/backend/src/postgre.ts b/packages/backend/src/postgre.ts
index 4b4490a0c..4f6b157d8 100644
--- a/packages/backend/src/postgre.ts
+++ b/packages/backend/src/postgre.ts
@@ -70,6 +70,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
 import { Webhook } from '@/models/entities/Webhook.js';
 import { Channel } from '@/models/entities/Channel.js';
 import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
+import { Flash } from '@/models/entities/Flash.js';
+import { FlashLike } from '@/models/entities/FlashLike.js';
 
 import { Config } from '@/config.js';
 import MisskeyLogger from '@/logger.js';
@@ -184,6 +186,8 @@ export const entities = [
 	Webhook,
 	UserIp,
 	RetentionAggregation,
+	Flash,
+	FlashLike,
 	...charts,
 ];
 
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 32eff7f31..60beca4f4 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -266,6 +266,15 @@ import * as ep___pages_like from './endpoints/pages/like.js';
 import * as ep___pages_show from './endpoints/pages/show.js';
 import * as ep___pages_unlike from './endpoints/pages/unlike.js';
 import * as ep___pages_update from './endpoints/pages/update.js';
+import * as ep___flash_create from './endpoints/flash/create.js';
+import * as ep___flash_delete from './endpoints/flash/delete.js';
+import * as ep___flash_featured from './endpoints/flash/featured.js';
+import * as ep___flash_like from './endpoints/flash/like.js';
+import * as ep___flash_show from './endpoints/flash/show.js';
+import * as ep___flash_unlike from './endpoints/flash/unlike.js';
+import * as ep___flash_update from './endpoints/flash/update.js';
+import * as ep___flash_my from './endpoints/flash/my.js';
+import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
 import * as ep___ping from './endpoints/ping.js';
 import * as ep___pinnedUsers from './endpoints/pinned-users.js';
 import * as ep___promo_read from './endpoints/promo/read.js';
@@ -587,6 +596,15 @@ const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_l
 const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default };
 const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default };
 const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default };
+const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default };
+const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default };
+const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default };
+const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default };
+const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default };
+const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default };
+const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default };
+const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default };
+const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default };
 const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
 const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
 const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
@@ -912,6 +930,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$pages_show,
 		$pages_unlike,
 		$pages_update,
+		$flash_create,
+		$flash_delete,
+		$flash_featured,
+		$flash_like,
+		$flash_show,
+		$flash_unlike,
+		$flash_update,
+		$flash_my,
+		$flash_myLikes,
 		$ping,
 		$pinnedUsers,
 		$promo_read,
@@ -1231,6 +1258,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$pages_show,
 		$pages_unlike,
 		$pages_update,
+		$flash_create,
+		$flash_delete,
+		$flash_featured,
+		$flash_like,
+		$flash_show,
+		$flash_unlike,
+		$flash_update,
+		$flash_my,
+		$flash_myLikes,
 		$ping,
 		$pinnedUsers,
 		$promo_read,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 49dc3b224..d4f8be5b8 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -265,6 +265,15 @@ import * as ep___pages_like from './endpoints/pages/like.js';
 import * as ep___pages_show from './endpoints/pages/show.js';
 import * as ep___pages_unlike from './endpoints/pages/unlike.js';
 import * as ep___pages_update from './endpoints/pages/update.js';
+import * as ep___flash_create from './endpoints/flash/create.js';
+import * as ep___flash_delete from './endpoints/flash/delete.js';
+import * as ep___flash_featured from './endpoints/flash/featured.js';
+import * as ep___flash_like from './endpoints/flash/like.js';
+import * as ep___flash_show from './endpoints/flash/show.js';
+import * as ep___flash_unlike from './endpoints/flash/unlike.js';
+import * as ep___flash_update from './endpoints/flash/update.js';
+import * as ep___flash_my from './endpoints/flash/my.js';
+import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
 import * as ep___ping from './endpoints/ping.js';
 import * as ep___pinnedUsers from './endpoints/pinned-users.js';
 import * as ep___promo_read from './endpoints/promo/read.js';
@@ -584,6 +593,15 @@ const eps = [
 	['pages/show', ep___pages_show],
 	['pages/unlike', ep___pages_unlike],
 	['pages/update', ep___pages_update],
+	['flash/create', ep___flash_create],
+	['flash/delete', ep___flash_delete],
+	['flash/featured', ep___flash_featured],
+	['flash/like', ep___flash_like],
+	['flash/show', ep___flash_show],
+	['flash/unlike', ep___flash_unlike],
+	['flash/update', ep___flash_update],
+	['flash/my', ep___flash_my],
+	['flash/my-likes', ep___flash_myLikes],
 	['ping', ep___ping],
 	['pinned-users', ep___pinnedUsers],
 	['promo/read', ep___promo_read],
diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts
new file mode 100644
index 000000000..a652047d9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/create.ts
@@ -0,0 +1,66 @@
+import ms from 'ms';
+import { Inject, Injectable } from '@nestjs/common';
+import type { DriveFilesRepository, FlashsRepository, PagesRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { Page } from '@/models/entities/Page.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { PageEntityService } from '@/core/entities/PageEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flash'],
+
+	requireCredential: true,
+
+	kind: 'write:flash',
+
+	limit: {
+		duration: ms('1hour'),
+		max: 10,
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		title: { type: 'string' },
+		summary: { type: 'string' },
+		script: { type: 'string' },
+		permissions: { type: 'array', items: {
+			type: 'string',
+		} },
+	},
+	required: ['title', 'summary', 'script', 'permissions'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		private flashEntityService: FlashEntityService,
+		private idService: IdService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.insert({
+				id: this.idService.genId(),
+				userId: me.id,
+				createdAt: new Date(),
+				updatedAt: new Date(),
+				title: ps.title,
+				summary: ps.summary,
+				script: ps.script,
+				permissions: ps.permissions,
+			}).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0]));
+
+			return await this.flashEntityService.pack(flash);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts
new file mode 100644
index 000000000..e94ede9f6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/delete.ts
@@ -0,0 +1,56 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { FlashsRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flashs'],
+
+	requireCredential: true,
+
+	kind: 'write:flash',
+
+	errors: {
+		noSuchFlash: {
+			message: 'No such flash.',
+			code: 'NO_SUCH_FLASH',
+			id: 'de1623ef-bbb3-4289-a71e-14cfa83d9740',
+		},
+
+		accessDenied: {
+			message: 'Access denied.',
+			code: 'ACCESS_DENIED',
+			id: '1036ad7b-9f92-4fff-89c3-0e50dc941704',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		flashId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['flashId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
+			if (flash == null) {
+				throw new ApiError(meta.errors.noSuchFlash);
+			}
+			if (flash.userId !== me.id) {
+				throw new ApiError(meta.errors.accessDenied);
+			}
+
+			await this.flashsRepository.delete(flash.id);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts
new file mode 100644
index 000000000..570aef96d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/featured.ts
@@ -0,0 +1,48 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { FlashsRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['flash'],
+
+	requireCredential: false,
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Flash',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		private flashEntityService: FlashEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.flashsRepository.createQueryBuilder('flash')
+				.andWhere('flash.likedCount > 0')
+				.orderBy('flash.likedCount', 'DESC');
+
+			const flashs = await query.take(10).getMany();
+
+			return await this.flashEntityService.packMany(flashs, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts
new file mode 100644
index 000000000..5581b8ec6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/like.ts
@@ -0,0 +1,87 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flash'],
+
+	requireCredential: true,
+
+	kind: 'write:flash-likes',
+
+	errors: {
+		noSuchFlash: {
+			message: 'No such flash.',
+			code: 'NO_SUCH_FLASH',
+			id: 'c07c1491-9161-4c5c-9d75-01906f911f73',
+		},
+
+		yourFlash: {
+			message: 'You cannot like your flash.',
+			code: 'YOUR_FLASH',
+			id: '3fd8a0e7-5955-4ba9-85bb-bf3e0c30e13b',
+		},
+
+		alreadyLiked: {
+			message: 'The flash has already been liked.',
+			code: 'ALREADY_LIKED',
+			id: '010065cf-ad43-40df-8067-abff9f4686e3',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		flashId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['flashId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		@Inject(DI.flashLikesRepository)
+		private flashLikesRepository: FlashLikesRepository,
+
+		private idService: IdService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
+			if (flash == null) {
+				throw new ApiError(meta.errors.noSuchFlash);
+			}
+
+			if (flash.userId === me.id) {
+				throw new ApiError(meta.errors.yourFlash);
+			}
+
+			// if already liked
+			const exist = await this.flashLikesRepository.findOneBy({
+				flashId: flash.id,
+				userId: me.id,
+			});
+
+			if (exist != null) {
+				throw new ApiError(meta.errors.alreadyLiked);
+			}
+
+			// Create like
+			await this.flashLikesRepository.insert({
+				id: this.idService.genId(),
+				createdAt: new Date(),
+				flashId: flash.id,
+				userId: me.id,
+			});
+
+			this.flashsRepository.increment({ id: flash.id }, 'likedCount', 1);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts
new file mode 100644
index 000000000..f7716ea74
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts
@@ -0,0 +1,68 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { FlashLikesRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['account', 'flash'],
+
+	requireCredential: true,
+
+	kind: 'read:flash-likes',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			properties: {
+				id: {
+					type: 'string',
+					optional: false, nullable: false,
+					format: 'id',
+				},
+				flash: {
+					type: 'object',
+					optional: false, nullable: false,
+					ref: 'Flash',
+				},
+			},
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashLikesRepository)
+		private flashLikesRepository: FlashLikesRepository,
+
+		private flashLikeEntityService: FlashLikeEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId)
+				.andWhere('like.userId = :meId', { meId: me.id })
+				.leftJoinAndSelect('like.flash', 'flash');
+
+			const likes = await query
+				.take(ps.limit)
+				.getMany();
+
+			return this.flashLikeEntityService.packMany(likes, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts
new file mode 100644
index 000000000..baed7f000
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/my.ts
@@ -0,0 +1,57 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { FlashsRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['account', 'flash'],
+
+	requireCredential: true,
+
+	kind: 'read:flash',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Flash',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		private flashEntityService: FlashEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId)
+				.andWhere('flash.userId = :meId', { meId: me.id });
+
+			const flashs = await query
+				.take(ps.limit)
+				.getMany();
+
+			return await this.flashEntityService.packMany(flashs);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/show.ts b/packages/backend/src/server/api/endpoints/flash/show.ts
new file mode 100644
index 000000000..48114c5a6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/show.ts
@@ -0,0 +1,60 @@
+import { IsNull } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import type { UsersRepository, FlashsRepository } from '@/models/index.js';
+import type { Flash } from '@/models/entities/Flash.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flashs'],
+
+	requireCredential: false,
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'Flash',
+	},
+
+	errors: {
+		noSuchFlash: {
+			message: 'No such flash.',
+			code: 'NO_SUCH_FLASH',
+			id: 'f0d34a1a-d29a-401d-90ba-1982122b5630',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		flashId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['flashId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		private flashEntityService: FlashEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
+
+			if (flash == null) {
+				throw new ApiError(meta.errors.noSuchFlash);
+			}
+
+			return await this.flashEntityService.pack(flash, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts
new file mode 100644
index 000000000..b994f5d34
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts
@@ -0,0 +1,68 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flash'],
+
+	requireCredential: true,
+
+	kind: 'write:flash-likes',
+
+	errors: {
+		noSuchFlash: {
+			message: 'No such flash.',
+			code: 'NO_SUCH_FLASH',
+			id: 'afe8424a-a69e-432d-a5f2-2f0740c62410',
+		},
+
+		notLiked: {
+			message: 'You have not liked that flash.',
+			code: 'NOT_LIKED',
+			id: '755f25a7-9871-4f65-9f34-51eaad9ae0ac',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		flashId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['flashId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		@Inject(DI.flashLikesRepository)
+		private flashLikesRepository: FlashLikesRepository,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
+			if (flash == null) {
+				throw new ApiError(meta.errors.noSuchFlash);
+			}
+
+			const exist = await this.flashLikesRepository.findOneBy({
+				flashId: flash.id,
+				userId: me.id,
+			});
+
+			if (exist == null) {
+				throw new ApiError(meta.errors.notLiked);
+			}
+
+			// Delete like
+			await this.flashLikesRepository.delete(exist.id);
+
+			this.flashsRepository.decrement({ id: flash.id }, 'likedCount', 1);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts
new file mode 100644
index 000000000..9ab17a61e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/flash/update.ts
@@ -0,0 +1,78 @@
+import ms from 'ms';
+import { Not } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import type { FlashsRepository, DriveFilesRepository } from '@/models/index.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['flash'],
+
+	requireCredential: true,
+
+	kind: 'write:flash',
+
+	limit: {
+		duration: ms('1hour'),
+		max: 300,
+	},
+
+	errors: {
+		noSuchFlash: {
+			message: 'No such flash.',
+			code: 'NO_SUCH_FLASH',
+			id: '611e13d2-309e-419a-a5e4-e0422da39b02',
+		},
+
+		accessDenied: {
+			message: 'Access denied.',
+			code: 'ACCESS_DENIED',
+			id: '08e60c88-5948-478e-a132-02ec701d67b2',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		flashId: { type: 'string', format: 'misskey:id' },
+		title: { type: 'string' },
+		summary: { type: 'string' },
+		script: { type: 'string' },
+		permissions: { type: 'array', items: {
+			type: 'string',
+		} },
+	},
+	required: ['flashId', 'title', 'summary', 'script', 'permissions'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.flashsRepository)
+		private flashsRepository: FlashsRepository,
+
+		@Inject(DI.driveFilesRepository)
+		private driveFilesRepository: DriveFilesRepository,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
+			if (flash == null) {
+				throw new ApiError(meta.errors.noSuchFlash);
+			}
+			if (flash.userId !== me.id) {
+				throw new ApiError(meta.errors.accessDenied);
+			}
+
+			await this.flashsRepository.update(flash.id, {
+				updatedAt: new Date(),
+				title: ps.title,
+				summary: ps.summary,
+				script: ps.script,
+				permissions: ps.permissions,
+			});
+		});
+	}
+}
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
new file mode 100644
index 000000000..bc1e25957
--- /dev/null
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -0,0 +1,107 @@
+<template>
+<div>
+	<div v-if="c.type === 'root'" :class="$style.root">
+		<template v-for="child in c.children" :key="child">
+			<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
+		</template>
+	</div>
+	<span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span>
+	<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, color: c.color ?? null }" :text="c.text"/>
+	<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton>
+	<div v-else-if="c.type === 'buttons'" style="display: flex; gap: 8px; flex-wrap: wrap;">
+		<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
+	</div>
+	<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
+		<template v-if="c.label" #label>{{ c.label }}</template>
+		<template v-if="c.caption" #caption>{{ c.caption }}</template>
+	</MkSwitch>
+	<MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput">
+		<template v-if="c.label" #label>{{ c.label }}</template>
+		<template v-if="c.caption" #caption>{{ c.caption }}</template>
+	</MkTextarea>
+	<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput">
+		<template v-if="c.label" #label>{{ c.label }}</template>
+		<template v-if="c.caption" #caption>{{ c.caption }}</template>
+	</MkInput>
+	<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput">
+		<template v-if="c.label" #label>{{ c.label }}</template>
+		<template v-if="c.caption" #caption>{{ c.caption }}</template>
+	</MkInput>
+	<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange">
+		<template v-if="c.label" #label>{{ c.label }}</template>
+		<template v-if="c.caption" #caption>{{ c.caption }}</template>
+		<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
+	</MkSelect>
+	<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="openPostForm">{{ c.text }}</MkButton>
+	<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
+		<template v-for="child in c.children" :key="child">
+			<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, onMounted, onUnmounted, Ref } from 'vue';
+import * as os from '@/os';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import { AsUiComponent } from '@/scripts/aiscript/ui';
+
+const props = withDefaults(defineProps<{
+	component: AsUiComponent;
+	components: Ref<AsUiComponent>[];
+	size: 'small' | 'medium' | 'large';
+}>(), {
+	size: 'medium',
+});
+
+const c = props.component;
+
+function g(id) {
+	return props.components.find(x => x.value.id === id).value;
+}
+
+let valueForSwitch = $ref(c.default ?? false);
+
+function onSwitchUpdate(v) {
+	valueForSwitch = v;
+	if (c.onChange) c.onChange(v);
+}
+
+function openPostForm() {
+	os.post({
+		initialText: c.form.text,
+		instant: true,
+	});
+}
+</script>
+
+<style lang="scss" module>
+.root {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.container {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.containerCenter {
+	text-align: center;
+}
+
+.fontSerif {
+	font-family: serif;
+}
+
+.fontMonospace {
+	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
+}
+</style>
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index daf47e12d..f9602de78 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -2,7 +2,7 @@
 <button
 	v-if="!link"
 	ref="el" class="bghgjjyj _button"
-	:class="{ inline, primary, gradate, danger, rounded, full, small }"
+	:class="{ inline, primary, gradate, danger, rounded, full, small, large, asLike }"
 	:type="type"
 	@click="emit('click', $event)"
 	@mousedown="onMousedown"
@@ -41,6 +41,8 @@ const props = defineProps<{
 	danger?: boolean;
 	full?: boolean;
 	small?: boolean;
+	large?: boolean;
+	asLike?: boolean;
 }>();
 
 const emit = defineEmits<{
@@ -131,6 +133,11 @@ function onMousedown(evt: MouseEvent): void {
 		padding: 6px 12px;
 	}
 
+	&.large {
+		font-size: 100%;
+		padding: 8px 16px;
+	}
+
 	&.full {
 		width: 100%;
 	}
@@ -153,6 +160,37 @@ function onMousedown(evt: MouseEvent): void {
 		}
 	}
 
+	&.asLike {
+		background: rgba(255, 86, 125, 0.07);
+		color: #ff002f;
+
+		&:not(:disabled):hover {
+			background: rgba(255, 74, 116, 0.11);
+		}
+
+		&:not(:disabled):active {
+			background: rgba(224, 57, 96, 0.125);
+		}
+
+		> .ripples {
+			::v-deep(div) {
+				background: rgba(255, 60, 106, 0.15);
+			}
+		}
+
+		&.primary {
+			background: rgb(241 97 132);
+
+			&:not(:disabled):hover {
+				background: rgb(241 92 128);
+			}
+
+			&:not(:disabled):active {
+				background: rgb(241 92 128);
+			}
+		}
+	}
+
 	&.gradate {
 		font-weight: bold;
 		color: var(--fgOnAccent) !important;
diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue
index f33f75372..8d2a2be8e 100644
--- a/packages/frontend/src/components/MkChartLegend.vue
+++ b/packages/frontend/src/components/MkChartLegend.vue
@@ -59,7 +59,7 @@ defineExpose({
 
 			&.disabled {
 				text-decoration: line-through;
-				opacity: 0.6;
+				opacity: 0.5;
 			}
 
 			> .box {
diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue
new file mode 100644
index 000000000..1a82ffe5a
--- /dev/null
+++ b/packages/frontend/src/components/MkFlashPreview.vue
@@ -0,0 +1,112 @@
+<template>
+<MkA :to="`/play/${flash.id}`" class="vhpxefrk _block" tabindex="-1">
+	<article>
+		<header>
+			<h1 :title="flash.title">{{ flash.title }}</h1>
+		</header>
+		<p v-if="flash.summary" :title="flash.summary">{{ flash.summary.length > 85 ? flash.summary.slice(0, 85) + '…' : flash.summary }}</p>
+		<footer>
+			<img class="icon" :src="flash.user.avatarUrl"/>
+			<p>{{ userName(flash.user) }}</p>
+		</footer>
+	</article>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import { userName } from '@/filters/user';
+import * as os from '@/os';
+
+const props = defineProps<{
+	//flash: misskey.entities.Flash;
+	flash: any;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.vhpxefrk {
+	display: block;
+
+	&:hover {
+		text-decoration: none;
+		color: var(--accent);
+	}
+
+	> article {
+		padding: 16px;
+
+		> header {
+			margin-bottom: 8px;
+
+			> h1 {
+				margin: 0;
+				font-size: 1em;
+				color: var(--urlPreviewTitle);
+			}
+		}
+
+		> p {
+			margin: 0;
+			color: var(--urlPreviewText);
+			font-size: 0.8em;
+		}
+
+		> footer {
+			margin-top: 8px;
+			height: 16px;
+
+			> img {
+				display: inline-block;
+				width: 16px;
+				height: 16px;
+				margin-right: 4px;
+				vertical-align: top;
+			}
+
+			> p {
+				display: inline-block;
+				margin: 0;
+				color: var(--urlPreviewInfo);
+				font-size: 0.8em;
+				line-height: 16px;
+				vertical-align: top;
+			}
+		}
+	}
+
+	@media (max-width: 700px) {
+	}
+
+	@media (max-width: 550px) {
+		font-size: 12px;
+
+		> article {
+			padding: 12px;
+		}
+	}
+
+	@media (max-width: 500px) {
+		font-size: 10px;
+		
+		> article {
+			padding: 8px;
+
+			> header {
+				margin-bottom: 4px;
+			}
+
+			> footer {
+				margin-top: 4px;
+
+				> img {
+					width: 12px;
+					height: 12px;
+				}
+			}
+		}
+	}
+}
+
+</style>
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 3ea90712a..aab7631e3 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -50,7 +50,7 @@ const menu = defaultStore.state.menu;
 
 const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
 	type: def.to ? 'link' : 'button',
-	text: i18n.ts[def.title],
+	text: def.title,
 	icon: def.icon,
 	to: def.to,
 	action: def.action,
diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue
index 009582e54..1eb61d434 100644
--- a/packages/frontend/src/components/MkPagePreview.vue
+++ b/packages/frontend/src/components/MkPagePreview.vue
@@ -14,22 +14,15 @@
 </MkA>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
 import { userName } from '@/filters/user';
 import * as os from '@/os';
 
-export default defineComponent({
-	props: {
-		page: {
-			type: Object,
-			required: true,
-		},
-	},
-	methods: {
-		userName,
-	},
-});
+const props = defineProps<{
+	page: misskey.entities.Page;
+}>();
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/frontend/src/components/form/select.vue b/packages/frontend/src/components/form/select.vue
index c8cdd9e50..068ca2ebc 100644
--- a/packages/frontend/src/components/form/select.vue
+++ b/packages/frontend/src/components/form/select.vue
@@ -126,7 +126,7 @@ const onClick = (ev: MouseEvent) => {
 	const pushOption = (option: VNode) => {
 		menu.push({
 			text: option.children,
-			active: v.value === option.props.value,
+			active: computed(() => v.value === option.props.value),
 			action: () => {
 				v.value = option.props.value;
 			},
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 31e6cd64a..efc0abfc6 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -8,97 +8,102 @@ import { unisonReload } from '@/scripts/unison-reload';
 
 export const navbarItemDef = reactive({
 	notifications: {
-		title: 'notifications',
+		title: i18n.ts.notifications,
 		icon: 'ti ti-bell',
 		show: computed(() => $i != null),
 		indicated: computed(() => $i != null && $i.hasUnreadNotification),
 		to: '/my/notifications',
 	},
 	messaging: {
-		title: 'messaging',
+		title: i18n.ts.messaging,
 		icon: 'ti ti-messages',
 		show: computed(() => $i != null),
 		indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
 		to: '/my/messaging',
 	},
 	drive: {
-		title: 'drive',
+		title: i18n.ts.drive,
 		icon: 'ti ti-cloud',
 		show: computed(() => $i != null),
 		to: '/my/drive',
 	},
 	followRequests: {
-		title: 'followRequests',
+		title: i18n.ts.followRequests,
 		icon: 'ti ti-user-plus',
 		show: computed(() => $i != null && $i.isLocked),
 		indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
 		to: '/my/follow-requests',
 	},
 	explore: {
-		title: 'explore',
+		title: i18n.ts.explore,
 		icon: 'ti ti-hash',
 		to: '/explore',
 	},
 	announcements: {
-		title: 'announcements',
+		title: i18n.ts.announcements,
 		icon: 'ti ti-speakerphone',
 		indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
 		to: '/announcements',
 	},
 	search: {
-		title: 'search',
+		title: i18n.ts.search,
 		icon: 'ti ti-search',
 		action: () => search(),
 	},
 	lists: {
-		title: 'lists',
+		title: i18n.ts.lists,
 		icon: 'ti ti-list',
 		show: computed(() => $i != null),
 		to: '/my/lists',
 	},
 	/*
 	groups: {
-		title: 'groups',
+		title: i18n.ts.groups,
 		icon: 'ti ti-users',
 		show: computed(() => $i != null),
 		to: '/my/groups',
 	},
 	*/
 	antennas: {
-		title: 'antennas',
+		title: i18n.ts.antennas,
 		icon: 'ti ti-antenna',
 		show: computed(() => $i != null),
 		to: '/my/antennas',
 	},
 	favorites: {
-		title: 'favorites',
+		title: i18n.ts.favorites,
 		icon: 'ti ti-star',
 		show: computed(() => $i != null),
 		to: '/my/favorites',
 	},
 	pages: {
-		title: 'pages',
+		title: i18n.ts.pages,
 		icon: 'ti ti-news',
 		to: '/pages',
 	},
+	play: {
+		title: 'Play',
+		icon: 'ti ti-player-play',
+		to: '/play',
+	},
 	gallery: {
-		title: 'gallery',
+		title: i18n.ts.gallery,
 		icon: 'ti ti-icons',
 		to: '/gallery',
 	},
 	clips: {
-		title: 'clip',
+		title: i18n.ts.clip,
 		icon: 'ti ti-paperclip',
 		show: computed(() => $i != null),
 		to: '/my/clips',
 	},
 	channels: {
-		title: 'channel',
+		title: i18n.ts.channel,
 		icon: 'ti ti-device-tv',
 		to: '/channels',
 	},
 	ui: {
-		title: 'switchUi',
+		title: i18n.ts.switchUi,
 		icon: 'ti ti-devices',
 		action: (ev) => {
 			os.popupMenu([{
@@ -126,7 +131,7 @@ export const navbarItemDef = reactive({
 		},
 	},
 	reload: {
-		title: 'reload',
+		title: i18n.ts.reload,
 		icon: 'ti ti-refresh',
 		action: (ev) => {
 			location.reload();
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
new file mode 100644
index 000000000..561331e00
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -0,0 +1,111 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<MkInput v-model="title" class="_formBlock">
+			<template #label>{{ i18n.ts._play.title }}</template>
+		</MkInput>
+		<MkTextarea v-model="summary" class="_formBlock">
+			<template #label>{{ i18n.ts._play.summary }}</template>
+		</MkTextarea>
+		<MkTextarea v-model="script" class="_formBlock _monospace" tall spellcheck="false">
+			<template #label>{{ i18n.ts._play.script }}</template>
+		</MkTextarea>
+		<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+			<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+			<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { url } from '@/config';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import { useRouter } from '@/router';
+
+const router = useRouter();
+
+const props = defineProps<{
+	id?: string;
+}>();
+
+let flash = $ref(null);
+
+if (props.id) {
+	flash = await os.api('flash/show', {
+		flashId: props.id,
+	});
+}
+
+let title = $ref(flash?.title ?? 'New Play');
+let summary = $ref(flash?.summary ?? '');
+let permissions = $ref(flash?.permissions ?? []);
+let script = $ref(flash?.script ?? `/// @ 0.12.0
+
+var name = ""
+
+Ui:render([
+	Ui:C:textInput({
+		label: "Your name"
+		onInput: @(v) { name = v }
+	})
+	Ui:C:button({
+		text: "Hello"
+		onClick: @() {
+			Mk:dialog(null \`Hello, {name}!\`)
+		}
+	})
+])
+`);
+
+async function save() {
+	if (flash) {
+		os.apiWithDialog('flash/update', {
+			flashId: props.id,
+			title,
+			summary,
+			permissions,
+			script,
+		});
+	} else {
+		const created = await os.apiWithDialog('flash/create', {
+			title,
+			summary,
+			permissions,
+			script,
+		});
+		router.push('/play/' + created.id + '/edit');
+	}
+}
+
+function show() {
+	if (flash == null) {
+		os.alert({
+			text: 'Please save',
+		});
+	} else {
+		os.pageWindow(`/play/${flash.id}`);
+	}
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => flash ? {
+	title: i18n.ts._play.edit + ': ' + flash.title,
+} : {
+	title: i18n.ts._play.new,
+}));
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
new file mode 100644
index 000000000..bc4828f41
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -0,0 +1,99 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="">
+			<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
+				<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
+			</MkPagination>
+		</div>
+
+		<div v-else-if="tab === 'my'" class="my">
+			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
+				<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
+			</MkPagination>
+		</div>
+
+		<div v-else-if="tab === 'liked'" class="">
+			<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
+				<MkFlashPreview v-for="like in items" :key="like.flash.id" class="" :flash="like.flash"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
+import MkFlashPreview from '@/components/MkFlashPreview.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkButton from '@/components/MkButton.vue';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredFlashsPagination = {
+	endpoint: 'flash/featured' as const,
+	noPaging: true,
+};
+const myFlashsPagination = {
+	endpoint: 'flash/my' as const,
+	limit: 5,
+};
+const likedFlashsPagination = {
+	endpoint: 'flash/my-likes' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/play/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'ti ti-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	key: 'featured',
+	title: i18n.ts._play.featured,
+	icon: 'fas fa-fire-alt',
+}, {
+	key: 'my',
+	title: i18n.ts._play.my,
+	icon: 'ti ti-edit',
+}, {
+	key: 'liked',
+	title: i18n.ts._play.liked,
+	icon: 'ti ti-heart',
+}]);
+
+definePageMetadata(computed(() => ({
+	title: 'Play',
+	icon: 'ti ti-player-play',
+})));
+</script>
+
+<style lang="scss" scoped>
+.rknalgpo {
+	&.my .ckltabjg:first-child {
+		margin-top: 16px;
+	}
+
+	.ckltabjg:not(:last-child) {
+		margin-bottom: 8px;
+	}
+
+	@media (min-width: 500px) {
+		.ckltabjg:not(:last-child) {
+			margin-bottom: 16px;
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
new file mode 100644
index 000000000..9495206c5
--- /dev/null
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -0,0 +1,291 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+			<div v-if="flash" :key="flash.id">
+				<Transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+					<div v-if="started" :class="$style.started">
+						<div class="main _panel">
+							<MkAsUi v-if="root" :component="root" :components="components"/>
+						</div>
+						<div class="actions _panel">
+							<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" as-like class="button" rounded primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
+							<MkButton v-else v-tooltip="i18n.ts.like" as-like class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
+							<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
+							<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
+						</div>
+					</div>
+					<div v-else :class="$style.ready">
+						<div class="_panel main">
+							<div class="title">{{ flash.title }}</div>
+							<div class="summary">{{ flash.summary }}</div>
+							<MkButton class="start" gradate rounded large @click="start">Play</MkButton>
+							<div class="info">
+								<span v-tooltip="i18n.ts.numberOfLikes"><i class="ti ti-heart"></i> {{ flash.likedCount }}</span>
+							</div>
+						</div>
+					</div>
+				</Transition>
+				<FormFolder class="_formBlock">
+					<template #icon><i class="ti ti-code"></i></template>
+					<template #label>{{ i18n.ts._play.viewSource }}</template>
+
+					<MkTextarea :model-value="flash.script" readonly tall class="_monospace" spellcheck="false"></MkTextarea>
+				</FormFolder>
+				<div :class="$style.footer">
+					<Mfm :text="`By @${flash.user.username}`"/>
+					<div class="date">
+						<div v-if="flash.createdAt != flash.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="flash.updatedAt" mode="detail"/></div>
+						<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="flash.createdAt" mode="detail"/></div>
+					</div>
+				</div>
+				<MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA>
+				<MkAd :prefer="['horizontal', 'horizontal-big']"/>
+			</div>
+			<MkError v-else-if="error" @retry="fetchPage()"/>
+			<MkLoading v-else/>
+		</Transition>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
+import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { url } from '@/config';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkPagePreview from '@/components/MkPagePreview.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkAsUi from '@/components/MkAsUi.vue';
+import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import FormFolder from '@/components/form/folder.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+
+const props = defineProps<{
+	id: string;
+}>();
+
+let flash = $ref(null);
+let error = $ref(null);
+
+function fetchFlash() {
+	flash = null;
+	os.api('flash/show', {
+		flashId: props.id,
+	}).then(_flash => {
+		flash = _flash;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: flash.title,
+		text: flash.summary,
+		url: `${url}/play/${flash.id}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${flash.title} ${url}/play/${flash.id}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('flash/like', {
+		flashId: flash.id,
+	}).then(() => {
+		flash.isLiked = true;
+		flash.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('flash/unlike', {
+		flashId: flash.id,
+	}).then(() => {
+		flash.isLiked = false;
+		flash.likedCount--;
+	});
+}
+
+watch(() => props.id, fetchFlash, { immediate: true });
+
+const parser = new Parser();
+
+let started = $ref(false);
+let aiscript = $shallowRef<Interpreter | null>(null);
+const root = ref<AsUiRoot>();
+const components: Ref<AsUiComponent>[] = [];
+
+function start() {
+	started = true;
+	run();
+}
+
+async function run() {
+	if (aiscript) aiscript.abort();
+
+	aiscript = new Interpreter({
+		...createAiScriptEnv({
+			storageKey: 'flash:' + flash.id,
+		}),
+		...registerAsUiLib(components, (_root) => {
+			root.value = _root.value;
+		}),
+	}, {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			// nop
+		},
+		log: (type, params) => {
+			// nop
+		},
+	});
+
+	let ast;
+	try {
+		ast = parser.parse(flash.script);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+	try {
+		await aiscript.exec(ast);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: 'AiScript Error',
+			text: err.message,
+		});
+	}
+}
+
+onDeactivated(() => {
+	if (aiscript) aiscript.abort();
+});
+
+onUnmounted(() => {
+	if (aiscript) aiscript.abort();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => flash ? {
+	title: flash.title,
+	avatar: flash.user,
+	path: `/play/${flash.id}`,
+	share: {
+		title: flash.title,
+		text: flash.summary,
+	},
+} : null));
+</script>
+
+<style lang="scss" module>
+.ready {
+	&:global {
+		> .main {
+			padding: 32px;
+
+			> .title {
+				font-size: 1.4em;
+				font-weight: bold;
+				margin-bottom: 1rem;
+				text-align: center;
+			}
+
+			> .summary {
+				font-size: 1.1em;
+				text-align: center;
+			}
+
+			> .start {
+				margin: 1em auto 1em auto;
+			}
+
+			> .info {
+				text-align: center;
+			}
+		}
+	}
+}
+
+.footer {
+	margin-top: 16px;
+
+	&:global {
+		> .date {
+			margin: 8px 0;
+			opacity: 0.6;
+		}
+	}
+}
+
+.started {
+	&:global {
+		> .main {
+			padding: 32px;
+		}
+
+		> .actions {
+			display: flex;
+			justify-content: center;
+			gap: 12px;
+			margin-top: 16px;
+			padding: 16px;
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.zoom-enter-active,
+.zoom-leave-active {
+	transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.zoom-enter-from {
+	opacity: 0;
+	transform: scale(0.7);
+}
+.zoom-leave-to {
+	opacity: 0;
+	transform: scale(1.3);
+}
+</style>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index e01dae2cd..11ed8f9f4 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -18,8 +18,8 @@
 					</div>
 					<div class="actions">
 						<div class="like">
-							<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
-							<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+							<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+							<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
 						</div>
 						<div class="other">
 							<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
@@ -207,20 +207,6 @@ definePageMetadata(computed(() => page ? {
 			padding: 16px 0 0 0;
 			border-top: solid 0.5px var(--divider);
 
-			> .like {
-				> .button {
-					--accent: rgb(241 97 132);
-					--X8: rgb(241 92 128);
-					--buttonBg: rgb(216 71 106 / 5%);
-					--buttonHoverBg: rgb(216 71 106 / 10%);
-					color: #ff002f;
-
-					::v-deep(.count) {
-						margin-left: 0.5em;
-					}
-				}
-			}
-
 			> .other {
 				margin-left: auto;
 
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index 9db17efc0..7d097fbaa 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -1,25 +1,34 @@
 <template>
-<div class="iltifgqe">
-	<div class="editor _panel _gap">
-		<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
-		<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
-	</div>
-
-	<MkContainer :foldable="true" class="_gap">
-		<template #header>{{ i18n.ts.output }}</template>
-		<div class="bepmlvbi">
-			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+<MkSpacer :content-max="800">
+	<div :class="$style.root">
+		<div :class="$style.editor" class="_panel">
+			<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
+			<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
 		</div>
-	</MkContainer>
 
-	<div class="_gap">
-		{{ i18n.ts.scratchpadDescription }}
+		<MkContainer v-if="root && components.length > 0" :key="uiKey" :foldable="true">
+			<template #header>UI</template>
+			<div :class="$style.ui">
+				<MkAsUi :component="root" :components="components" size="small"/>
+			</div>
+		</MkContainer>
+
+		<MkContainer :foldable="true" class="">
+			<template #header>{{ i18n.ts.output }}</template>
+			<div :class="$style.logs">
+				<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+			</div>
+		</MkContainer>
+
+		<div class="">
+			{{ i18n.ts.scratchpadDescription }}
+		</div>
 	</div>
-</div>
+</MkSpacer>
 </template>
 
 <script lang="ts" setup>
-import { ref, watch } from 'vue';
+import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -35,11 +44,16 @@ import * as os from '@/os';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
 import { definePageMetadata } from '@/scripts/page-metadata';
+import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
+import MkAsUi from '@/components/MkAsUi.vue';
 
 const parser = new Parser();
-
+let aiscript: Interpreter;
 const code = ref('');
 const logs = ref<any[]>([]);
+const root = ref<AsUiRoot>();
+let components: Ref<AsUiComponent>[] = [];
+let uiKey = $ref(0);
 
 const saved = localStorage.getItem('scratchpad');
 if (saved) {
@@ -51,10 +65,19 @@ watch(code, () => {
 });
 
 async function run() {
+	if (aiscript) aiscript.abort();
+	root.value = undefined;
+	components = [];
+	uiKey++;
 	logs.value = [];
-	const aiscript = new Interpreter(createAiScriptEnv({
-		storageKey: 'scratchpad',
-		token: $i?.token,
+	aiscript = new Interpreter(({
+		...createAiScriptEnv({
+			storageKey: 'widget',
+			token: $i?.token,
+		}),
+		...registerAsUiLib(components, (_root) => {
+			root.value = _root.value;
+		}),
 	}), {
 		in: (q) => {
 			return new Promise(ok => {
@@ -96,10 +119,11 @@ async function run() {
 	}
 	try {
 		await aiscript.exec(ast);
-	} catch (error: any) {
+	} catch (err: any) {
 		os.alert({
 			type: 'error',
-			text: error.message,
+			title: 'AiScript Error',
+			text: err.message,
 		});
 	}
 }
@@ -108,6 +132,14 @@ function highlighter(code) {
 	return highlight(code, languages.js, 'javascript');
 }
 
+onDeactivated(() => {
+	if (aiscript) aiscript.abort();
+});
+
+onUnmounted(() => {
+	if (aiscript) aiscript.abort();
+});
+
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => []);
@@ -118,21 +150,29 @@ definePageMetadata({
 });
 </script>
 
-<style lang="scss" scoped>
-.iltifgqe {
-	padding: 16px;
-
-	> .editor {
-		position: relative;
-	}
+<style lang="scss" module>
+.root {
+	display: flex;
+	flex-direction: column;
+	gap: var(--margin);
 }
 
-.bepmlvbi {
+.editor {
+	position: relative;
+}
+
+.ui {
+	padding: 32px;
+}
+
+.logs {
 	padding: 16px;
 
-	> .log {
-		&:not(.print) {
-			opacity: 0.7;
+	&:global {
+		> .log {
+			&:not(.print) {
+				opacity: 0.7;
+			}
 		}
 	}
 }
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index 0b2776ec9..9ab8700b0 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -49,7 +49,7 @@ async function addItem() {
 	const { canceled, result: item } = await os.select({
 		title: i18n.ts.addItem,
 		items: [...menu.map(k => ({
-			value: k, text: i18n.ts[navbarItemDef[k].title],
+			value: k, text: navbarItemDef[k].title,
 		})), {
 			value: '-', text: i18n.ts.divider,
 		}],
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 9001f0f37..63c753de2 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -262,6 +262,20 @@ export const routes = [{
 }, {
 	path: '/pages',
 	component: page(() => import('./pages/pages.vue')),
+}, {
+	path: '/play/:id/edit',
+	component: page(() => import('./pages/flash/flash-edit.vue')),
+	loginRequired: true,
+}, {
+	path: '/play/new',
+	component: page(() => import('./pages/flash/flash-edit.vue')),
+	loginRequired: true,
+}, {
+	path: '/play/:id',
+	component: page(() => import('./pages/flash/flash.vue')),
+}, {
+	path: '/play',
+	component: page(() => import('./pages/flash/flash-index.vue')),
 }, {
 	path: '/gallery/:postId/edit',
 	component: page(() => import('./pages/gallery/edit.vue')),
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
new file mode 100644
index 000000000..f4c89b827
--- /dev/null
+++ b/packages/frontend/src/scripts/aiscript/ui.ts
@@ -0,0 +1,526 @@
+import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
+import { v4 as uuid } from 'uuid';
+import { ref, Ref } from 'vue';
+
+export type AsUiComponentBase = {
+	id: string;
+	hidden?: boolean;
+};
+
+export type AsUiRoot = AsUiComponentBase & {
+	type: 'root';
+	children: AsUiComponent['id'][];
+};
+
+export type AsUiContainer = AsUiComponentBase & {
+	type: 'container';
+	children?: AsUiComponent['id'][];
+	align?: 'left' | 'center' | 'right';
+	bgColor?: string;
+	fgColor?: string;
+	font?: 'serif' | 'sans-serif' | 'monospace';
+	borderWidth?: number;
+	borderColor?: string;
+	padding?: number;
+	rounded?: boolean;
+	hidden?: boolean;
+};
+
+export type AsUiText = AsUiComponentBase & {
+	type: 'text';
+	text?: string;
+	size?: number;
+	bold?: boolean;
+	color?: string;
+	font?: 'serif' | 'sans-serif' | 'monospace';
+};
+
+export type AsUiMfm = AsUiComponentBase & {
+	type: 'mfm';
+	text?: string;
+	size?: number;
+	color?: string;
+	font?: 'serif' | 'sans-serif' | 'monospace';
+};
+
+export type AsUiButton = AsUiComponentBase & {
+	type: 'button';
+	text?: string;
+	onClick?: () => void;
+	primary?: boolean;
+	rounded?: boolean;
+};
+
+export type AsUiButtons = AsUiComponentBase & {
+	type: 'buttons';
+	buttons?: AsUiButton[];
+};
+
+export type AsUiSwitch = AsUiComponentBase & {
+	type: 'switch';
+	onChange?: (v: boolean) => void;
+	default?: boolean;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiTextarea = AsUiComponentBase & {
+	type: 'textarea';
+	onInput?: (v: string) => void;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiTextInput = AsUiComponentBase & {
+	type: 'textInput';
+	onInput?: (v: string) => void;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiNumberInput = AsUiComponentBase & {
+	type: 'numberInput';
+	onInput?: (v: number) => void;
+	default?: number;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiSelect = AsUiComponentBase & {
+	type: 'select';
+	items?: {
+		text: string;
+		value: string;
+	}[];
+	onChange?: (v: string) => void;
+	default?: string;
+	label?: string;
+	caption?: string;
+};
+
+export type AsUiPostFormButton = AsUiComponentBase & {
+	type: 'postFormButton';
+	text?: string;
+	primary?: boolean;
+	rounded?: boolean;
+	form?: {
+		text: string;
+	};
+};
+
+export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiPostFormButton;
+
+export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
+	// TODO
+}
+
+function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const children = def.value.get('children');
+	utils.assertArray(children);
+
+	return {
+		children: children.value.map(v => {
+			utils.assertObject(v);
+			return v.value.get('id').value;
+		}),
+	};
+}
+
+function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const children = def.value.get('children');
+	if (children) utils.assertArray(children);
+	const align = def.value.get('align');
+	if (align) utils.assertString(align);
+	const bgColor = def.value.get('bgColor');
+	if (bgColor) utils.assertString(bgColor);
+	const fgColor = def.value.get('fgColor');
+	if (fgColor) utils.assertString(fgColor);
+	const font = def.value.get('font');
+	if (font) utils.assertString(font);
+	const borderWidth = def.value.get('borderWidth');
+	if (borderWidth) utils.assertNumber(borderWidth);
+	const borderColor = def.value.get('borderColor');
+	if (borderColor) utils.assertString(borderColor);
+	const padding = def.value.get('padding');
+	if (padding) utils.assertNumber(padding);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+	const hidden = def.value.get('hidden');
+	if (hidden) utils.assertBoolean(hidden);
+
+	return {
+		children: children ? children.value.map(v => {
+			utils.assertObject(v);
+			return v.value.get('id').value;
+		}) : [],
+		align: align?.value,
+		fgColor: fgColor?.value,
+		bgColor: bgColor?.value,
+		font: font?.value,
+		borderWidth: borderWidth?.value,
+		borderColor: borderColor?.value,
+		padding: padding?.value,
+		rounded: rounded?.value,
+		hidden: hidden?.value,
+	};
+}
+
+function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const size = def.value.get('size');
+	if (size) utils.assertNumber(size);
+	const bold = def.value.get('bold');
+	if (bold) utils.assertBoolean(bold);
+	const color = def.value.get('color');
+	if (color) utils.assertString(color);
+	const font = def.value.get('font');
+	if (font) utils.assertString(font);
+
+	return {
+		text: text?.value,
+		size: size?.value,
+		bold: bold?.value,
+		color: color?.value,
+		font: font?.value,
+	};
+}
+
+function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const size = def.value.get('size');
+	if (size) utils.assertNumber(size);
+	const color = def.value.get('color');
+	if (color) utils.assertString(color);
+	const font = def.value.get('font');
+	if (font) utils.assertString(font);
+
+	return {
+		text: text?.value,
+		size: size?.value,
+		color: color?.value,
+		font: font?.value,
+	};
+}
+
+function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: (v) => {
+			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: (v) => {
+			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const onInput = def.value.get('onInput');
+	if (onInput) utils.assertFunction(onInput);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertNumber(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onInput: (v) => {
+			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const onClick = def.value.get('onClick');
+	if (onClick) utils.assertFunction(onClick);
+	const primary = def.value.get('primary');
+	if (primary) utils.assertBoolean(primary);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+
+	return {
+		text: text?.value,
+		onClick: () => {
+			if (onClick) call(onClick, []);
+		},
+		primary: primary?.value,
+		rounded: rounded?.value,
+	};
+}
+
+function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const buttons = def.value.get('buttons');
+	if (buttons) utils.assertArray(buttons);
+
+	return {
+		buttons: buttons ? buttons.value.map(button => {
+			utils.assertObject(button);
+			const text = button.value.get('text');
+			utils.assertString(text);
+			const onClick = button.value.get('onClick');
+			utils.assertFunction(onClick);
+			const primary = button.value.get('primary');
+			if (primary) utils.assertBoolean(primary);
+			const rounded = button.value.get('rounded');
+			if (rounded) utils.assertBoolean(rounded);
+
+			return {
+				text: text.value,
+				onClick: () => {
+					call(onClick, []);
+				},
+				primary: primary?.value,
+				rounded: rounded?.value,
+			};
+		}) : [],
+	};
+}
+
+function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const onChange = def.value.get('onChange');
+	if (onChange) utils.assertFunction(onChange);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertBoolean(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		onChange: (v) => {
+			if (onChange) call(onChange, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const items = def.value.get('items');
+	if (items) utils.assertArray(items);
+	const onChange = def.value.get('onChange');
+	if (onChange) utils.assertFunction(onChange);
+	const defaultValue = def.value.get('default');
+	if (defaultValue) utils.assertString(defaultValue);
+	const label = def.value.get('label');
+	if (label) utils.assertString(label);
+	const caption = def.value.get('caption');
+	if (caption) utils.assertString(caption);
+
+	return {
+		items: items ? items.value.map(item => {
+			utils.assertObject(item);
+			const text = item.value.get('text');
+			utils.assertString(text);
+			const value = item.value.get('value');
+			if (value) utils.assertString(value);
+			return {
+				text: text.value,
+				value: value ? value.value : text.value,
+			};
+		}) : [],
+		onChange: (v) => {
+			if (onChange) call(onChange, [utils.jsToVal(v)]);
+		},
+		default: defaultValue?.value,
+		label: label?.value,
+		caption: caption?.value,
+	};
+}
+
+function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
+	utils.assertObject(def);
+
+	const text = def.value.get('text');
+	if (text) utils.assertString(text);
+	const primary = def.value.get('primary');
+	if (primary) utils.assertBoolean(primary);
+	const rounded = def.value.get('rounded');
+	if (rounded) utils.assertBoolean(rounded);
+	const form = def.value.get('form');
+	if (form) utils.assertObject(form);
+
+	const getForm = () => {
+		const text = form!.value.get('text');
+		utils.assertString(text);
+		return {
+			text: text.value,
+		};
+	};
+
+	return {
+		text: text?.value,
+		primary: primary?.value,
+		rounded: rounded?.value,
+		form: form ? getForm() : {
+			text: '',
+		},
+	};
+}
+
+export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
+	const instances = {};
+
+	function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
+		if (id) utils.assertString(id);
+		const _id = id?.value ?? uuid();
+		const component = ref({
+			...getOptions(def, call),
+			type,
+			id: _id,
+		});
+		components.push(component);
+		const instance = values.OBJ(new Map([
+			['id', values.STR(_id)],
+			['update', values.FN_NATIVE(async ([def], opts) => {
+				utils.assertObject(def);
+				const updates = getOptions(def, call);
+				for (const update of def.value.keys()) {
+					if (!Object.hasOwn(updates, update)) continue;
+					component.value[update] = updates[update];
+				}
+			})],
+		]));
+		instances[_id] = instance;
+		return instance;
+	}
+
+	const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {});
+	const rootComponent = components[0] as Ref<AsUiRoot>;
+	done(rootComponent);
+
+	return {
+		'Ui:root': rootInstance,
+
+		'Ui:patch': values.FN_NATIVE(async ([id, val], opts) => {
+			utils.assertString(id);
+			utils.assertArray(val);
+			patch(id.value, val.value, opts.call);
+		}),
+
+		'Ui:get': values.FN_NATIVE(async ([id], opts) => {
+			utils.assertString(id);
+			const instance = instances[id.value];
+			if (instance) {
+				return instance;
+			} else {
+				return values.NULL;
+			}
+		}),
+
+		// Ui:root.update({ children: [...] }) の糖衣構文
+		'Ui:render': values.FN_NATIVE(async ([children], opts) => {
+			utils.assertArray(children);
+		
+			rootComponent.value.children = children.value.map(v => {
+				utils.assertObject(v);
+				return v.value.get('id').value;
+			});
+		}),
+
+		'Ui:C:container': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('container', def, id, getContainerOptions, opts.call);
+		}),
+
+		'Ui:C:text': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('text', def, id, getTextOptions, opts.call);
+		}),
+
+		'Ui:C:mfm': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('mfm', def, id, getMfmOptions, opts.call);
+		}),
+
+		'Ui:C:textarea': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('textarea', def, id, getTextareaOptions, opts.call);
+		}),
+
+		'Ui:C:textInput': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('textInput', def, id, getTextInputOptions, opts.call);
+		}),
+
+		'Ui:C:numberInput': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.call);
+		}),
+
+		'Ui:C:button': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('button', def, id, getButtonOptions, opts.call);
+		}),
+
+		'Ui:C:buttons': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('buttons', def, id, getButtonsOptions, opts.call);
+		}),
+
+		'Ui:C:switch': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('switch', def, id, getSwitchOptions, opts.call);
+		}),
+
+		'Ui:C:select': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('select', def, id, getSelectOptions, opts.call);
+		}),
+
+		'Ui:C:postFormButton': values.FN_NATIVE(async ([def, id], opts) => {
+			return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.call);
+		}),
+	};
+}
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index ac109d9de..989d861d2 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -14,7 +14,7 @@
 			<template v-for="item in menu">
 				<div v-if="item === '-'" class="divider"></div>
 				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
-					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
+					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
 					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
 				</component>
 			</template>
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 7c859bf3a..e90098397 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -17,14 +17,14 @@
 					:is="navbarItemDef[item].to ? 'MkA' : 'button'"
 					v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
 					v-click-anime
-					v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
+					v-tooltip.noDelay.right="navbarItemDef[item].title"
 					class="item _button"
 					:class="[item, { active: navbarItemDef[item].active }]"
 					active-class="active"
 					:to="navbarItemDef[item].to"
 					v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
 				>
-					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
+					<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
 					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
 				</component>
 			</template>
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index 77a64aac3..34ddfa1d3 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -10,7 +10,7 @@
 			</MkA>
 			<template v-for="item in menu">
 				<div v-if="item === '-'" class="divider"></div>
-				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+				<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
 					<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
 					<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
 				</component>
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index ec379fbaa..a11c2ba10 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -15,7 +15,7 @@
 	<template v-for="item in menu">
 		<div v-if="item === '-'" class="divider"></div>
 		<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
-			<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span>
+			<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
 			<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
 		</component>
 	</template>
diff --git a/packages/frontend/src/widgets/aiscript-app.vue b/packages/frontend/src/widgets/aiscript-app.vue
new file mode 100644
index 000000000..1445e5b1a
--- /dev/null
+++ b/packages/frontend/src/widgets/aiscript-app.vue
@@ -0,0 +1,122 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscriptApp">
+	<template #header>App</template>
+	<div :class="$style.root">
+		<MkAsUi v-if="root" :component="root" :components="components" size="small"/>
+	</div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, Ref, ref, watch } from 'vue';
+import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import MkAsUi from '@/components/MkAsUi.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
+
+const name = 'aiscriptApp';
+
+const widgetPropsDef = {
+	script: {
+		type: 'string' as const,
+		multiline: true,
+		default: '',
+	},
+	showHeader: {
+		type: 'boolean' as const,
+		default: true,
+	},
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+	widgetPropsDef,
+	props,
+	emit,
+);
+
+const parser = new Parser();
+
+const root = ref<AsUiRoot>();
+const components: Ref<AsUiComponent>[] = [];
+
+async function run() {
+	const aiscript = new Interpreter({
+		...createAiScriptEnv({
+			storageKey: 'widget',
+			token: $i?.token,
+		}),
+		...registerAsUiLib(components, (_root) => {
+			root.value = _root.value;
+		}),
+	}, {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
+			});
+		},
+		out: (value) => {
+			// nop
+		},
+		log: (type, params) => {
+			// nop
+		},
+	});
+
+	let ast;
+	try {
+		ast = parser.parse(widgetProps.script);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :(',
+		});
+		return;
+	}
+	try {
+		await aiscript.exec(ast);
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: 'AiScript Error',
+			text: err.message,
+		});
+	}
+}
+
+watch(() => widgetProps.script, () => {
+	run();
+});
+
+onMounted(() => {
+	run();
+});
+
+defineExpose<WidgetComponentExpose>({
+	name,
+	configure,
+	id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 16px;
+}
+</style>
diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts
index 39826f13c..3966649da 100644
--- a/packages/frontend/src/widgets/index.ts
+++ b/packages/frontend/src/widgets/index.ts
@@ -22,6 +22,7 @@ export default function(app: App) {
 	app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
 	app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
 	app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
+	app.component('MkwAiscriptApp', defineAsyncComponent(() => import('./aiscript-app.vue')));
 	app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
 	app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
 }
@@ -48,6 +49,7 @@ export const widgets = [
 	'jobQueue',
 	'button',
 	'aiscript',
+	'aiscriptApp',
 	'aichan',
 	'userList',
 ];