summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/large-stingrays-fry.md21
-rw-r--r--.eslintrc.cjs8
-rw-r--r--.github/scripts/bundle-size.mjs1
-rw-r--r--packages/astro/src/@types/astro.ts36
-rw-r--r--packages/astro/src/core/config/schema.ts2
-rw-r--r--packages/astro/src/core/config/settings.ts1
-rw-r--r--packages/astro/src/core/create-vite.ts2
-rw-r--r--packages/astro/src/integrations/index.ts3
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/overlay.ts505
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts69
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts94
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts50
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts103
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts72
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts66
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts28
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts157
-rw-r--r--packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts76
-rw-r--r--packages/astro/src/vite-plugin-astro-server/route.ts21
-rw-r--r--packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts27
20 files changed, 1341 insertions, 1 deletions
diff --git a/.changeset/large-stingrays-fry.md b/.changeset/large-stingrays-fry.md
new file mode 100644
index 000000000..a70e37714
--- /dev/null
+++ b/.changeset/large-stingrays-fry.md
@@ -0,0 +1,21 @@
+---
+'astro': minor
+---
+
+
+Dev Overlay (experimental)
+
+Provides a new dev overlay for your browser preview that allows you to inspect your page islands, see helpful audits on performance and accessibility, and more. A Dev Overlay Plugin API is also included to allow you to add new features and third-party integrations to it.
+
+You can enable access to the dev overlay and its API by adding the following flag to your Astro config:
+
+```ts
+// astro.config.mjs
+export default {
+ experimental: {
+ devOverlay: true
+ }
+};
+```
+
+Read the [Dev Overlay Plugin API documentation](https://docs.astro.build/en/reference/dev-overlay-plugin-reference/) for information about building your own plugins to integrate with Astro's dev overlay.
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 70c4b0ffa..e6085ac64 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -1,5 +1,7 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
const { builtinModules } = require('module');
+/** @type {import("@types/eslint").Linter.Config} */
module.exports = {
extends: [
'plugin:@typescript-eslint/recommended-type-checked',
@@ -75,6 +77,12 @@ module.exports = {
},
},
{
+ files: ['packages/astro/src/runtime/client/**/*.ts'],
+ env: {
+ browser: true,
+ },
+ },
+ {
files: ['packages/**/test/*.js', 'packages/**/*.js'],
env: {
mocha: true,
diff --git a/.github/scripts/bundle-size.mjs b/.github/scripts/bundle-size.mjs
index 66911eab1..f1c9ceab0 100644
--- a/.github/scripts/bundle-size.mjs
+++ b/.github/scripts/bundle-size.mjs
@@ -68,6 +68,7 @@ async function bundle(files) {
sourcemap: false,
target: ['es2018'],
outdir: 'out',
+ external: ['astro:*'],
metafile: true,
})
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index a0fa48ddb..1bcbe20a9 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -21,6 +21,7 @@ import type { TSConfig } from '../core/config/tsconfig.js';
import type { AstroCookies } from '../core/cookies/index.js';
import type { ResponseWithEncoding } from '../core/endpoint/index.js';
import type { AstroIntegrationLogger, Logger, LoggerLevel } from '../core/logger/core.js';
+import type { Icon } from '../runtime/client/dev-overlay/ui-library/icons.js';
import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server/index.js';
import type { OmitIndexSignature, Simplify } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
@@ -1351,6 +1352,25 @@ export interface AstroUserConfig {
* ```
*/
optimizeHoistedScript?: boolean;
+
+ /**
+ * @docs
+ * @name experimental.devOverlay
+ * @type {boolean}
+ * @default `false`
+ * @version 3.4.0
+ * @description
+ * Enable a dev overlay in development mode. This overlay allows you to inspect your page islands, see helpful audits on performance and accessibility, and more.
+ *
+ * ```js
+ * {
+ * experimental: {
+ * devOverlay: true,
+ * },
+ * }
+ * ```
+ */
+ devOverlay?: boolean;
};
}
@@ -1524,6 +1544,7 @@ export interface AstroSettings {
* Map of directive name (e.g. `load`) to the directive script code
*/
clientDirectives: Map<string, string>;
+ devOverlayPlugins: string[];
tsConfig: TSConfig | undefined;
tsConfigPath: string | undefined;
watchFiles: string[];
@@ -2049,6 +2070,7 @@ export interface AstroIntegration {
injectScript: (stage: InjectedScriptStage, content: string) => void;
injectRoute: (injectRoute: InjectedRoute) => void;
addClientDirective: (directive: ClientDirectiveConfig) => void;
+ addDevOverlayPlugin: (entrypoint: string) => void;
logger: AstroIntegrationLogger;
// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.
// This may require some refactoring of `scripts`, `styles`, and `links` into something
@@ -2284,3 +2306,17 @@ export interface ClientDirectiveConfig {
name: string;
entrypoint: string;
}
+
+export interface DevOverlayPlugin {
+ id: string;
+ name: string;
+ icon: Icon;
+ init?(canvas: ShadowRoot, eventTarget: EventTarget): void | Promise<void>;
+}
+
+export type DevOverlayMetadata = Window &
+ typeof globalThis & {
+ __astro_dev_overlay__: {
+ root: string;
+ };
+ };
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 82fbdc3b4..ee470abc8 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -55,6 +55,7 @@ const ASTRO_CONFIG_DEFAULTS = {
redirects: {},
experimental: {
optimizeHoistedScript: false,
+ devOverlay: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -297,6 +298,7 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript),
+ devOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.devOverlay),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`
diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts
index 07f22a33c..cf4db7598 100644
--- a/packages/astro/src/core/config/settings.ts
+++ b/packages/astro/src/core/config/settings.ts
@@ -98,6 +98,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
scripts: [],
clientDirectives: getDefaultClientDirectives(),
watchFiles: [],
+ devOverlayPlugins: [],
timer: new AstroTimer(),
};
}
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index fd23a27f5..6a459be2a 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -16,6 +16,7 @@ import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.j
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
import astroVitePlugin from '../vite-plugin-astro/index.js';
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
+import astroDevOverlay from '../vite-plugin-dev-overlay/vite-plugin-dev-overlay.js';
import envVitePlugin from '../vite-plugin-env/index.js';
import astroHeadPlugin from '../vite-plugin-head/index.js';
import htmlVitePlugin from '../vite-plugin-html/index.js';
@@ -134,6 +135,7 @@ export async function createVite(
vitePluginSSRManifest(),
astroAssetsPlugin({ settings, logger, mode }),
astroTransitions(),
+ astroDevOverlay({ settings, logger }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts
index 5485794c5..268721025 100644
--- a/packages/astro/src/integrations/index.ts
+++ b/packages/astro/src/integrations/index.ts
@@ -125,6 +125,9 @@ export async function runHookConfigSetup({
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
},
+ addDevOverlayPlugin: (entrypoint) => {
+ updatedSettings.devOverlayPlugins.push(entrypoint);
+ },
addClientDirective: ({ name, entrypoint }) => {
if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
throw new Error(
diff --git a/packages/astro/src/runtime/client/dev-overlay/overlay.ts b/packages/astro/src/runtime/client/dev-overlay/overlay.ts
new file mode 100644
index 000000000..57ca72d63
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/overlay.ts
@@ -0,0 +1,505 @@
+/* eslint-disable no-console */
+// @ts-expect-error
+import { loadDevOverlayPlugins } from 'astro:dev-overlay';
+import type { DevOverlayPlugin as DevOverlayPluginDefinition } from '../../../@types/astro.js';
+import astroDevToolPlugin from './plugins/astro.js';
+import astroAuditPlugin from './plugins/audit.js';
+import astroXrayPlugin from './plugins/xray.js';
+import { DevOverlayCard } from './ui-library/card.js';
+import { DevOverlayHighlight } from './ui-library/highlight.js';
+import { getIconElement, isDefinedIcon, type Icon } from './ui-library/icons.js';
+import { DevOverlayTooltip } from './ui-library/tooltip.js';
+import { DevOverlayWindow } from './ui-library/window.js';
+
+type DevOverlayPlugin = DevOverlayPluginDefinition & {
+ active: boolean;
+ status: 'ready' | 'loading' | 'error';
+ eventTarget: EventTarget;
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const WS_EVENT_NAME = 'astro-dev-overlay';
+ const HOVER_DELAY = 750;
+
+ const builtinPlugins: DevOverlayPlugin[] = [
+ astroDevToolPlugin,
+ astroXrayPlugin,
+ astroAuditPlugin,
+ ].map((plugin) => ({
+ ...plugin,
+ active: false,
+ status: 'loading',
+ eventTarget: new EventTarget(),
+ }));
+
+ const customPluginsImports = (await loadDevOverlayPlugins()) as DevOverlayPluginDefinition[];
+ const customPlugins: DevOverlayPlugin[] = [];
+ customPlugins.push(
+ ...customPluginsImports.map((plugin) => ({
+ ...plugin,
+ active: false,
+ status: 'loading' as const,
+ eventTarget: new EventTarget(),
+ }))
+ );
+
+ const plugins: DevOverlayPlugin[] = [...builtinPlugins, ...customPlugins];
+
+ for (const plugin of plugins) {
+ plugin.eventTarget.addEventListener('plugin-notification', (evt) => {
+ const target = overlay.shadowRoot?.querySelector(`[data-plugin-id="${plugin.id}"]`);
+ if (!target) return;
+
+ let newState = true;
+ if (evt instanceof CustomEvent) {
+ newState = evt.detail.state ?? true;
+ }
+
+ target.querySelector('.notification')?.toggleAttribute('data-active', newState);
+ });
+ }
+
+ class AstroDevOverlay extends HTMLElement {
+ shadowRoot: ShadowRoot;
+ hoverTimeout: number | undefined;
+ isHidden: () => boolean = () => this.devOverlay?.hasAttribute('data-hidden') ?? true;
+ devOverlay: HTMLDivElement | undefined;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'closed' });
+ }
+
+ // connect component
+ async connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ #dev-overlay {
+ position: fixed;
+ bottom: 7.5%;
+ left: calc(50% + 32px);
+ transform: translate(-50%, 0%);
+ z-index: 999999;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ transition: bottom 0.2s ease-in-out;
+ pointer-events: none;
+ }
+
+ #dev-overlay[data-hidden] {
+ bottom: -40px;
+ }
+
+ #dev-overlay[data-hidden]:hover, #dev-overlay[data-hidden]:focus-within {
+ bottom: -35px;
+ cursor: pointer;
+ }
+
+ #dev-overlay[data-hidden] #minimize-button {
+ visibility: hidden;
+ }
+
+ #dev-bar {
+ height: 56px;
+ overflow: hidden;
+ pointer-events: auto;
+
+ background: linear-gradient(180deg, #13151A 0%, rgba(19, 21, 26, 0.88) 100%);
+ box-shadow: 0px 0px 0px 0px #13151A4D;
+ border: 1px solid #343841;
+ border-radius: 9999px;
+ }
+
+ #dev-bar .item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 64px;
+ border: 0;
+ background: transparent;
+ color: white;
+ font-family: system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ }
+
+ #dev-bar #bar-container .item:hover, #dev-bar #bar-container .item:focus {
+ background: rgba(27, 30, 36, 1);
+ cursor: pointer;
+ outline-offset: -3px;
+ }
+
+ #dev-bar .item:first-of-type {
+ border-top-left-radius: 9999px;
+ border-bottom-left-radius: 9999px;
+ }
+
+ #dev-bar .item:last-of-type {
+ border-top-right-radius: 9999px;
+ border-bottom-right-radius: 9999px;
+ }
+ #dev-bar #bar-container .item.active {
+ background: rgba(71, 78, 94, 1);
+ }
+
+ #dev-bar #bar-container .item.active .notification {
+ border-color: rgba(71, 78, 94, 1);
+ }
+
+ #dev-bar .item .icon {
+ position: relative;
+ max-width: 24px;
+ max-height: 24px;
+ user-select: none;
+ }
+
+ #dev-bar .item svg {
+ width: 24px;
+ height: 24px;
+ display: block;
+ margin: auto;
+ }
+
+ #dev-bar .item .notification {
+ display: none;
+ position: absolute;
+ top: -2px;
+ right: 0;
+ width: 8px;
+ height: 8px;
+ border-radius: 9999px;
+ border: 1px solid rgba(19, 21, 26, 1);
+ background: #B33E66;
+ }
+
+ #dev-bar .item .notification[data-active] {
+ display: block;
+ }
+
+ #dev-bar #bar-container {
+ height: 100%;
+ display: flex;
+ }
+
+ #dev-bar .separator {
+ background: rgba(52, 56, 65, 1);
+ width: 1px;
+ }
+
+ astro-overlay-plugin-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+
+ astro-overlay-plugin-canvas:not([data-active]) {
+ display: none;
+ }
+
+ #minimize-button {
+ width: 32px;
+ height: 32px;
+ background: rgba(255, 255, 255, 0.75);
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ pointer-events: auto;
+ border: 0;
+ color: white;
+ font-family: system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ #minimize-button:hover, #minimize-button:focus {
+ cursor: pointer;
+ background: rgba(255, 255, 255, 0.90);
+ }
+
+ #minimize-button svg {
+ width: 16px;
+ height: 16px;
+ }
+
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ </style>
+
+ <div id="dev-overlay">
+ <div id="dev-bar">
+ <div id="bar-container">
+ ${builtinPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')}
+ <div class="separator"></div>
+ ${customPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')}
+ </div>
+ </div>
+ <button id="minimize-button">${getIconElement('arrow-down')?.outerHTML}</button>
+ </div>`;
+
+ this.devOverlay = this.shadowRoot.querySelector<HTMLDivElement>('#dev-overlay')!;
+ this.attachEvents();
+
+ // Init plugin lazily
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(async () => {
+ await this.initAllPlugins();
+ });
+ } else {
+ // Fallback to setTimeout for.. Safari...
+ setTimeout(async () => {
+ await this.initAllPlugins();
+ }, 200);
+ }
+ }
+
+ attachEvents() {
+ const items = this.shadowRoot.querySelectorAll<HTMLDivElement>('.item');
+ items.forEach((item) => {
+ item.addEventListener('click', async (e) => {
+ const target = e.currentTarget;
+ if (!target || !(target instanceof HTMLElement)) return;
+
+ const id = target.dataset.pluginId;
+ if (!id) return;
+
+ const plugin = this.getPluginById(id);
+ if (!plugin) return;
+
+ if (plugin.status === 'loading') {
+ await this.initPlugin(plugin);
+ }
+
+ this.togglePluginStatus(plugin);
+ });
+ });
+
+ const minimizeButton = this.shadowRoot.querySelector<HTMLDivElement>('#minimize-button');
+ if (minimizeButton && this.devOverlay) {
+ minimizeButton.addEventListener('click', () => {
+ this.toggleOverlay(false);
+ this.toggleMinimizeButton(false);
+ });
+ }
+
+ const devBar = this.shadowRoot.querySelector<HTMLDivElement>('#dev-bar');
+ if (devBar) {
+ // On hover:
+ // - If the overlay is hidden, show it after the hover delay
+ // - If the overlay is visible, show the minimize button after the hover delay
+ (['mouseenter', 'focusin'] as const).forEach((event) => {
+ devBar.addEventListener(event, () => {
+ if (this.hoverTimeout) {
+ window.clearTimeout(this.hoverTimeout);
+ }
+
+ if (this.isHidden()) {
+ this.hoverTimeout = window.setTimeout(() => {
+ this.toggleOverlay(true);
+ }, HOVER_DELAY);
+ } else {
+ this.hoverTimeout = window.setTimeout(() => {
+ this.toggleMinimizeButton(true);
+ }, HOVER_DELAY);
+ }
+ });
+ });
+
+ // On unhover:
+ // - Reset every timeout, as to avoid showing the overlay/minimize button when the user didn't really want to hover
+ // - If the overlay is visible, hide the minimize button after the hover delay
+ devBar.addEventListener('mouseleave', () => {
+ if (this.hoverTimeout) {
+ window.clearTimeout(this.hoverTimeout);
+ }
+
+ if (!this.isHidden()) {
+ this.hoverTimeout = window.setTimeout(() => {
+ this.toggleMinimizeButton(false);
+ }, HOVER_DELAY);
+ }
+ });
+
+ // On click, show the overlay if it's hidden, it's likely the user wants to interact with it
+ devBar.addEventListener('click', () => {
+ if (!this.isHidden()) return;
+ this.toggleOverlay(true);
+ });
+
+ devBar.addEventListener('keyup', (event) => {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ if (!this.isHidden()) return;
+ this.toggleOverlay(true);
+ }
+ });
+ }
+ }
+
+ async initAllPlugins() {
+ await Promise.all(
+ plugins
+ .filter((plugin) => plugin.status === 'loading')
+ .map((plugin) => this.initPlugin(plugin))
+ );
+ }
+
+ async initPlugin(plugin: DevOverlayPlugin) {
+ if (plugin.status === 'ready') return;
+
+ const shadowRoot = this.getPluginCanvasById(plugin.id)!.shadowRoot!;
+
+ try {
+ console.info(`Initing plugin ${plugin.id}`);
+ await plugin.init?.(shadowRoot, plugin.eventTarget);
+ plugin.status = 'ready';
+
+ if (import.meta.hot) {
+ import.meta.hot.send(`${WS_EVENT_NAME}:${plugin.id}:init`);
+ }
+ } catch (e) {
+ console.error(`Failed to init plugin ${plugin.id}, error: ${e}`);
+ plugin.status = 'error';
+ }
+ }
+
+ getPluginTemplate(plugin: DevOverlayPlugin) {
+ return `<button class="item" data-plugin-id="${plugin.id}">
+ <div class="icon">${this.getPluginIcon(plugin.icon)}<div class="notification"></div></div>
+ <span class="sr-only">${plugin.name}</span>
+ </button>`;
+ }
+
+ getPluginIcon(icon: Icon) {
+ if (isDefinedIcon(icon)) {
+ return getIconElement(icon)?.outerHTML;
+ }
+
+ return icon;
+ }
+
+ getPluginById(id: string) {
+ return plugins.find((plugin) => plugin.id === id);
+ }
+
+ getPluginCanvasById(id: string) {
+ return this.shadowRoot.querySelector(`astro-overlay-plugin-canvas[data-plugin-id="${id}"]`);
+ }
+
+ togglePluginStatus(plugin: DevOverlayPlugin, status?: boolean) {
+ plugin.active = status ?? !plugin.active;
+ const target = this.shadowRoot.querySelector(`[data-plugin-id="${plugin.id}"]`);
+ if (!target) return;
+ target.classList.toggle('active', plugin.active);
+ this.getPluginCanvasById(plugin.id)?.toggleAttribute('data-active', plugin.active);
+
+ plugin.eventTarget.dispatchEvent(
+ new CustomEvent('plugin-toggle', {
+ detail: {
+ state: plugin.active,
+ plugin,
+ },
+ })
+ );
+
+ if (import.meta.hot) {
+ import.meta.hot.send(`${WS_EVENT_NAME}:${plugin.id}:toggle`, { state: plugin.active });
+ }
+ }
+
+ toggleMinimizeButton(newStatus?: boolean) {
+ const minimizeButton = this.shadowRoot.querySelector<HTMLDivElement>('#minimize-button');
+ if (!minimizeButton) return;
+
+ if (newStatus !== undefined) {
+ if (newStatus === true) {
+ minimizeButton.removeAttribute('inert');
+ minimizeButton.style.opacity = '1';
+ } else {
+ minimizeButton.setAttribute('inert', '');
+ minimizeButton.style.opacity = '0';
+ }
+ } else {
+ minimizeButton.toggleAttribute('inert');
+ minimizeButton.style.opacity = minimizeButton.hasAttribute('inert') ? '0' : '1';
+ }
+ }
+
+ toggleOverlay(newStatus?: boolean) {
+ const barContainer = this.shadowRoot.querySelector<HTMLDivElement>('#bar-container');
+ const devBar = this.shadowRoot.querySelector<HTMLDivElement>('#dev-bar');
+
+ if (newStatus !== undefined) {
+ if (newStatus === true) {
+ this.devOverlay?.removeAttribute('data-hidden');
+ barContainer?.removeAttribute('inert');
+ devBar?.removeAttribute('tabindex');
+ } else {
+ this.devOverlay?.setAttribute('data-hidden', '');
+ barContainer?.setAttribute('inert', '');
+ devBar?.setAttribute('tabindex', '0');
+ }
+ } else {
+ this.devOverlay?.toggleAttribute('data-hidden');
+ barContainer?.toggleAttribute('inert');
+ if (this.isHidden()) {
+ devBar?.setAttribute('tabindex', '0');
+ } else {
+ devBar?.removeAttribute('tabindex');
+ }
+ }
+ }
+ }
+
+ class DevOverlayCanvas extends HTMLElement {
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'closed' });
+ }
+
+ // connect component
+ async connectedCallback() {
+ this.shadowRoot.innerHTML = ``;
+ }
+ }
+
+ customElements.define('astro-dev-overlay', AstroDevOverlay);
+ customElements.define('astro-overlay-window', DevOverlayWindow);
+ customElements.define('astro-overlay-plugin-canvas', DevOverlayCanvas);
+ customElements.define('astro-overlay-tooltip', DevOverlayTooltip);
+ customElements.define('astro-overlay-highlight', DevOverlayHighlight);
+ customElements.define('astro-overlay-card', DevOverlayCard);
+
+ const overlay = document.createElement('astro-dev-overlay');
+ overlay.style.zIndex = '999999';
+ document.body.append(overlay);
+
+ // Create plugin canvases
+ plugins.forEach((plugin) => {
+ const pluginCanvas = document.createElement('astro-overlay-plugin-canvas');
+ pluginCanvas.dataset.pluginId = plugin.id;
+ overlay.shadowRoot?.append(pluginCanvas);
+ });
+});
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts
new file mode 100644
index 000000000..3629776ea
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts
@@ -0,0 +1,69 @@
+import type { DevOverlayPlugin } from '../../../../@types/astro.js';
+import type { DevOverlayWindow } from '../ui-library/window.js';
+
+export default {
+ id: 'astro',
+ name: 'Astro',
+ icon: 'astro:logo',
+ init(canvas) {
+ const astroWindow = document.createElement('astro-overlay-window') as DevOverlayWindow;
+
+ astroWindow.windowTitle = 'Astro';
+ astroWindow.windowIcon = 'astro:logo';
+
+ astroWindow.innerHTML = `
+ <style>
+ #buttons-container {
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+ }
+
+ #buttons-container astro-overlay-card {
+ flex: 1;
+ }
+
+ footer {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ }
+
+ footer a {
+ color: rgba(145, 152, 173, 1);
+ }
+
+ footer a:hover {
+ color: rgba(204, 206, 216, 1);
+ }
+
+ #main-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ height: 100%;
+ }
+
+ p {
+ margin-top: 0;
+ }
+ </style>
+
+ <div id="main-container">
+ <div>
+ <p>Welcome to Astro!</p>
+ <div id="buttons-container">
+ <astro-overlay-card icon="astro:logo" link="https://github.com/withastro/astro/issues/new/choose">Report an issue</astro-overlay-card>
+ <astro-overlay-card icon="astro:logo" link="https://docs.astro.build/en/getting-started/">View Astro Docs</astro-overlay-card>
+ </div>
+ </div>
+ <footer>
+ <a href="https://discord.gg/astro" target="_blank">Join the Astro Discord</a>
+ <a href="https://astro.build" target="_blank">Visit Astro.build</a>
+ </footer>
+ </div>
+ `;
+
+ canvas.append(astroWindow);
+ },
+} satisfies DevOverlayPlugin;
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts
new file mode 100644
index 000000000..bb94ead94
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts
@@ -0,0 +1,94 @@
+import type { DevOverlayPlugin } from '../../../../@types/astro.js';
+import type { DevOverlayHighlight } from '../ui-library/highlight.js';
+import type { DevOverlayTooltip } from '../ui-library/tooltip.js';
+import { attachTooltipToHighlight, createHighlight, positionHighlight } from './utils/highlight.js';
+
+const icon =
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 16"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>';
+
+interface AuditRule {
+ title: string;
+ message: string;
+}
+
+const selectorBasedRules: (AuditRule & { selector: string })[] = [
+ {
+ title: 'Missing `alt` tag',
+ message: 'The alt attribute is important for accessibility.',
+ selector: 'img:not([alt])',
+ },
+];
+
+export default {
+ id: 'astro:audit',
+ name: 'Audit',
+ icon: icon,
+ init(canvas, eventTarget) {
+ let audits: { highlightElement: DevOverlayHighlight; auditedElement: HTMLElement }[] = [];
+
+ selectorBasedRules.forEach((rule) => {
+ document.querySelectorAll(rule.selector).forEach((el) => {
+ createAuditProblem(rule, el);
+ });
+ });
+
+ if (audits.length > 0) {
+ eventTarget.dispatchEvent(
+ new CustomEvent('plugin-notification', {
+ detail: {
+ state: true,
+ },
+ })
+ );
+ }
+
+ function createAuditProblem(rule: AuditRule, originalElement: Element) {
+ const computedStyle = window.getComputedStyle(originalElement);
+ const targetedElement = (originalElement.children[0] as HTMLElement) || originalElement;
+
+ // If the element is hidden, don't do anything
+ if (targetedElement.offsetParent === null || computedStyle.display === 'none') {
+ return;
+ }
+
+ const rect = originalElement.getBoundingClientRect();
+ const highlight = createHighlight(rect, 'warning');
+ const tooltip = buildAuditTooltip(rule);
+ attachTooltipToHighlight(highlight, tooltip, originalElement);
+
+ canvas.append(highlight);
+ audits.push({ highlightElement: highlight, auditedElement: originalElement as HTMLElement });
+
+ (['scroll', 'resize'] as const).forEach((event) => {
+ window.addEventListener(event, () => {
+ audits.forEach(({ highlightElement, auditedElement }) => {
+ const newRect = auditedElement.getBoundingClientRect();
+ positionHighlight(highlightElement, newRect);
+ });
+ });
+ });
+ }
+
+ function buildAuditTooltip(rule: AuditRule) {
+ const tooltip = document.createElement('astro-overlay-tooltip') as DevOverlayTooltip;
+ tooltip.sections = [
+ {
+ icon: 'warning',
+ title: rule.title,
+ },
+ {
+ content: rule.message,
+ },
+ // TODO: Add a link to the file
+ // Needs https://github.com/withastro/compiler/pull/375
+ // {
+ // content: '/src/somewhere/component.astro',
+ // clickDescription: 'Click to go to file',
+ // clickAction() {},
+ // },
+ ];
+
+ return tooltip;
+ }
+ },
+} satisfies DevOverlayPlugin;
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts
new file mode 100644
index 000000000..34bfd1f5a
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts
@@ -0,0 +1,50 @@
+import type { DevOverlayHighlight } from '../../ui-library/highlight.js';
+import type { Icon } from '../../ui-library/icons.js';
+
+export function createHighlight(rect: DOMRect, icon?: Icon) {
+ const highlight = document.createElement('astro-overlay-highlight') as DevOverlayHighlight;
+ if (icon) highlight.icon = icon;
+
+ highlight.tabIndex = 0;
+
+ positionHighlight(highlight, rect);
+ return highlight;
+}
+
+export function positionHighlight(highlight: DevOverlayHighlight, rect: DOMRect) {
+ // Make an highlight that is 10px bigger than the element on all sides
+ highlight.style.top = `${Math.max(rect.top + window.scrollY - 10, 0)}px`;
+ highlight.style.left = `${Math.max(rect.left + window.scrollX - 10, 0)}px`;
+ highlight.style.width = `${rect.width + 15}px`;
+ highlight.style.height = `${rect.height + 15}px`;
+}
+
+export function attachTooltipToHighlight(
+ highlight: DevOverlayHighlight,
+ tooltip: HTMLElement,
+ originalElement: Element
+) {
+ highlight.shadowRoot.append(tooltip);
+
+ (['mouseover', 'focus'] as const).forEach((event) => {
+ highlight.addEventListener(event, () => {
+ tooltip.dataset.show = 'true';
+ const originalRect = originalElement.getBoundingClientRect();
+ const dialogRect = tooltip.getBoundingClientRect();
+
+ // If the tooltip is going to be off the screen, show it above the element instead
+ if (originalRect.top < dialogRect.height) {
+ // Not enough space above, show below
+ tooltip.style.top = `${originalRect.height + 15}px`;
+ } else {
+ tooltip.style.top = `-${tooltip.offsetHeight}px`;
+ }
+ });
+ });
+
+ (['mouseout', 'blur'] as const).forEach((event) => {
+ highlight.addEventListener(event, () => {
+ tooltip.dataset.show = 'false';
+ });
+ });
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts
new file mode 100644
index 000000000..123cab8f3
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts
@@ -0,0 +1,103 @@
+import type { DevOverlayMetadata, DevOverlayPlugin } from '../../../../@types/astro.js';
+import type { DevOverlayHighlight } from '../ui-library/highlight.js';
+import type { DevOverlayTooltip } from '../ui-library/tooltip.js';
+import { attachTooltipToHighlight, createHighlight, positionHighlight } from './utils/highlight.js';
+
+const icon =
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M7.9 1.5v-.4a1.1 1.1 0 0 1 2.2 0v.4a1.1 1.1 0 1 1-2.2 0Zm-6.4 8.6a1.1 1.1 0 1 0 0-2.2h-.4a1.1 1.1 0 0 0 0 2.2h.4ZM12 3.7a1.1 1.1 0 0 0 1.4-.7l.4-1.1a1.1 1.1 0 0 0-2.1-.8l-.4 1.2a1.1 1.1 0 0 0 .7 1.4Zm-9.7 7.6-1.2.4a1.1 1.1 0 1 0 .8 2.1l1-.4a1.1 1.1 0 1 0-.6-2ZM20.8 17a1.9 1.9 0 0 1 0 2.6l-1.2 1.2a1.9 1.9 0 0 1-2.6 0l-4.3-4.2-1.6 3.6a1.9 1.9 0 0 1-1.7 1.2A1.9 1.9 0 0 1 7.5 20L2.7 5a1.9 1.9 0 0 1 2.4-2.4l15 5a1.9 1.9 0 0 1 .2 3.4l-3.7 1.6 4.2 4.3ZM19 18.3 14.6 14a1.9 1.9 0 0 1 .6-3l3.2-1.5L5.1 5.1l4.3 13.3 1.5-3.2a1.9 1.9 0 0 1 3-.6l4.4 4.4.7-.7Z"/></svg>';
+
+export default {
+ id: 'astro:xray',
+ name: 'Xray',
+ icon: icon,
+ init(canvas) {
+ let islandsOverlays: { highlightElement: DevOverlayHighlight; island: HTMLElement }[] = [];
+ addIslandsOverlay();
+
+ function addIslandsOverlay() {
+ const islands = document.querySelectorAll<HTMLElement>('astro-island');
+
+ islands.forEach((island) => {
+ const computedStyle = window.getComputedStyle(island);
+ const islandElement = (island.children[0] as HTMLElement) || island;
+
+ // If the island is hidden, don't show an overlay on it
+ if (islandElement.offsetParent === null || computedStyle.display === 'none') {
+ return;
+ }
+
+ const rect = islandElement.getBoundingClientRect();
+ const highlight = createHighlight(rect);
+ const tooltip = buildIslandTooltip(island);
+ attachTooltipToHighlight(highlight, tooltip, islandElement);
+
+ canvas.append(highlight);
+ islandsOverlays.push({ highlightElement: highlight, island: islandElement });
+ });
+
+ (['scroll', 'resize'] as const).forEach((event) => {
+ window.addEventListener(event, () => {
+ islandsOverlays.forEach(({ highlightElement, island: islandElement }) => {
+ const newRect = islandElement.getBoundingClientRect();
+ positionHighlight(highlightElement, newRect);
+ });
+ });
+ });
+ }
+
+ function buildIslandTooltip(island: HTMLElement) {
+ const tooltip = document.createElement('astro-overlay-tooltip') as DevOverlayTooltip;
+ tooltip.sections = [];
+
+ const islandProps = island.getAttribute('props')
+ ? JSON.parse(island.getAttribute('props')!)
+ : {};
+ const islandClientDirective = island.getAttribute('client');
+
+ // Add the component client's directive if we have one
+ if (islandClientDirective) {
+ tooltip.sections.push({
+ title: 'Client directive',
+ inlineTitle: `<code>client:${islandClientDirective}</code>`,
+ });
+ }
+
+ // Add the props if we have any
+ if (Object.keys(islandProps).length > 0) {
+ tooltip.sections.push({
+ title: 'Props',
+ content: `${Object.entries(islandProps)
+ .map((prop) => `<code>${prop[0]}=${getPropValue(prop[1] as any)}</code>`)
+ .join(', ')}`,
+ });
+ }
+
+ // Add a click action to go to the file
+ const islandComponentPath = island.getAttribute('component-url');
+ if (islandComponentPath) {
+ tooltip.sections.push({
+ content: islandComponentPath,
+ clickDescription: 'Click to go to file',
+ async clickAction() {
+ // NOTE: The path here has to be absolute and without any errors (no double slashes etc)
+ // or Vite will silently fail to open the file. Quite annoying.
+ await fetch(
+ '/__open-in-editor?file=' +
+ encodeURIComponent(
+ (window as DevOverlayMetadata).__astro_dev_overlay__.root +
+ islandComponentPath.slice(1)
+ )
+ );
+ },
+ });
+ }
+
+ return tooltip;
+ }
+
+ function getPropValue(prop: [number, any]) {
+ const [_, value] = prop;
+ return JSON.stringify(value, null, 2);
+ }
+ },
+} satisfies DevOverlayPlugin;
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts
new file mode 100644
index 000000000..debba9786
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts
@@ -0,0 +1,72 @@
+import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
+
+export class DevOverlayCard extends HTMLElement {
+ icon?: Icon;
+ link?: string | undefined | null;
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.link = this.getAttribute('link');
+ this.icon = this.hasAttribute('icon') ? (this.getAttribute('icon') as Icon) : undefined;
+ }
+
+ connectedCallback() {
+ const element = this.link ? 'a' : 'button';
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ a, button {
+ display: block;
+ padding: 40px 16px;
+ border-radius: 8px;
+ border: 1px solid rgba(35, 38, 45, 1);
+ color: #fff;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 19px;
+ text-decoration: none;
+ }
+
+ a:hover, button:hover {
+ background: rgba(136, 58, 234, 0.33);
+ border: 1px solid rgba(113, 24, 226, 1)
+ }
+
+ svg {
+ display: block;
+ margin: 0 auto;
+ }
+
+ span {
+ margin-top: 8px;
+ display: block;
+ text-align: center;
+ }
+ </style>
+
+ <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``}>
+ ${this.icon ? this.getElementForIcon(this.icon) : ''}
+ <span><slot /></span>
+ </${element}>
+ `;
+ }
+
+ getElementForIcon(icon: Icon) {
+ let iconElement;
+ if (isDefinedIcon(icon)) {
+ iconElement = getIconElement(icon);
+ } else {
+ iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = icon;
+ }
+
+ iconElement?.style.setProperty('height', '24px');
+ iconElement?.style.setProperty('width', '24px');
+
+ return iconElement?.outerHTML ?? '';
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts
new file mode 100644
index 000000000..7d91535e0
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts
@@ -0,0 +1,66 @@
+import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
+
+export class DevOverlayHighlight extends HTMLElement {
+ icon?: Icon | undefined | null;
+
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.icon = this.hasAttribute('icon') ? (this.getAttribute('icon') as Icon) : undefined;
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ background: linear-gradient(180deg, rgba(224, 204, 250, 0.33) 0%, rgba(224, 204, 250, 0.0825) 100%);
+ border: 1px solid rgba(113, 24, 226, 1);
+ border-radius: 4px;
+ display: block;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ }
+
+ .icon {
+ width: 24px;
+ height: 24px;
+ background: linear-gradient(0deg, #B33E66, #B33E66), linear-gradient(0deg, #351722, #351722);
+ border: 1px solid rgba(53, 23, 34, 1);
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ top: -15px;
+ right: -15px;
+ }
+ </style>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.icon) {
+ let iconContainer = document.createElement('div');
+ iconContainer.classList.add('icon');
+
+ let iconElement;
+ if (isDefinedIcon(this.icon)) {
+ iconElement = getIconElement(this.icon);
+ } else {
+ iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = this.icon;
+ }
+
+ if (iconElement) {
+ iconElement?.style.setProperty('width', '16px');
+ iconElement?.style.setProperty('height', '16px');
+
+ iconContainer.append(iconElement);
+ this.shadowRoot.append(iconContainer);
+ }
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts
new file mode 100644
index 000000000..a471249b3
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts
@@ -0,0 +1,28 @@
+export type DefinedIcon = keyof typeof icons;
+export type Icon = DefinedIcon | (string & NonNullable<unknown>);
+
+export function isDefinedIcon(icon: Icon): icon is DefinedIcon {
+ return icon in icons;
+}
+
+export function getIconElement(
+ name: keyof typeof icons | (string & NonNullable<unknown>)
+): SVGElement | undefined {
+ const icon = icons[name as keyof typeof icons];
+
+ if (!icon) {
+ return undefined;
+ }
+
+ const svgFragment = new DocumentFragment();
+ svgFragment.append(document.createRange().createContextualFragment(icon));
+
+ return svgFragment.firstElementChild as SVGElement;
+}
+
+const icons = {
+ 'astro:logo': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 85 107"><path fill="#fff" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="url(#a)" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="#fff" d="M0 69.6s14.3-7 28.7-7l10.8-33.5c.4-1.6 1.6-2.7 3-2.7 1.2 0 2.4 1.1 2.8 2.7l10.9 33.5c17 0 28.6 7 28.6 7L60.5 3.2c-.7-2-2-3.2-3.5-3.2H27.8c-1.6 0-2.7 1.3-3.4 3.2L0 69.6Z"/><defs><linearGradient id="a" x1="22.5" x2="69.1" y1="107" y2="84.9" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>`,
+ warning: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#fff" d="M8 .40625c-1.5019 0-2.97007.445366-4.21886 1.27978C2.53236 2.52044 1.55905 3.70642.984293 5.094.40954 6.48157.259159 8.00842.552165 9.48147.845172 10.9545 1.56841 12.3076 2.63041 13.3696c1.06201 1.062 2.41508 1.7852 3.88813 2.0782 1.47304.293 2.99989.1427 4.38746-.4321 1.3876-.5747 2.5736-1.5481 3.408-2.7968.8344-1.2488 1.2798-2.717 1.2798-4.2189-.0023-2.0133-.8031-3.9435-2.2267-5.36713C11.9435 1.20925 10.0133.408483 8 .40625ZM8 13.9062c-1.16814 0-2.31006-.3463-3.28133-.9953-.97128-.649-1.7283-1.5715-2.17533-2.6507-.44703-1.0792-.56399-2.26675-.3361-3.41245.22789-1.1457.79041-2.1981 1.61641-3.0241.82601-.826 1.8784-1.38852 3.0241-1.61641 1.1457-.2279 2.33325-.11093 3.41245.3361 1.0793.44703 2.0017 1.20405 2.6507 2.17532.649.97128.9954 2.11319.9954 3.28134-.0017 1.56592-.6245 3.0672-1.7318 4.1745S9.56592 13.9046 8 13.9062Zm-.84375-5.62495V4.625c0-.22378.0889-.43839.24713-.59662.15824-.15824.37285-.24713.59662-.24713.22378 0 .43839.08889.59662.24713.15824.15823.24713.37284.24713.59662v3.65625c0 .22378-.08889.43839-.24713.59662C8.43839 9.03611 8.22378 9.125 8 9.125c-.22377 0-.43838-.08889-.59662-.24713-.15823-.15823-.24713-.37284-.24713-.59662ZM9.125 11.0938c0 .2225-.06598.44-.18959.625-.12362.185-.29932.3292-.50489.4143-.20556.0852-.43176.1074-.64999.064-.21823-.0434-.41869-.1505-.57602-.3079-.15734-.1573-.26448-.3577-.30789-.576-.04341-.2182-.02113-.4444.06402-.65.08515-.2055.22934-.3812.41435-.5049.185-.1236.40251-.18955.62501-.18955.29837 0 .58452.11855.7955.32955.21098.2109.3295.4971.3295.7955Z"/></svg>`,
+ 'arrow-down':
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 14"><path fill="#13151A" d="m11.0306 8.53063-4.5 4.49997c-.06968.0699-.15247.1254-.24364.1633-.09116.0378-.1889.0573-.28761.0573-.09871 0-.19645-.0195-.28762-.0573-.09116-.0379-.17395-.0934-.24363-.1633L.968098 8.53063c-.140896-.1409-.220051-.332-.220051-.53125 0-.19926.079155-.39036.220051-.53125.140892-.1409.331992-.22006.531252-.22006.19926 0 .39035.07916.53125.22006l3.21937 3.21937V1.5c0-.19891.07902-.38968.21967-.53033C5.61029.829018 5.80106.75 5.99997.75c.19891 0 .38968.079018.53033.21967.14065.14065.21967.33142.21967.53033v9.1875l3.21938-3.22c.14085-.1409.33195-.22005.53125-.22005.1993 0 .3904.07915.5312.22005.1409.1409.2201.33199.2201.53125s-.0792.39035-.2201.53125l-.0012.00063Z"/></svg>',
+} as const;
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts
new file mode 100644
index 000000000..63244ab6b
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts
@@ -0,0 +1,157 @@
+import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
+
+export interface DevOverlayTooltipSection {
+ title?: string;
+ inlineTitle?: string;
+ icon?: Icon;
+ content?: string;
+ clickAction?: () => void | Promise<void>;
+ clickDescription?: string;
+}
+
+export class DevOverlayTooltip extends HTMLElement {
+ sections: DevOverlayTooltipSection[] = [];
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ position: absolute;
+ display: none;
+ color: white;
+ background: linear-gradient(0deg, #310A65, #310A65), linear-gradient(0deg, #7118E2, #7118E2);
+ border: 1px solid rgba(113, 24, 226, 1);
+ border-radius: 4px;
+ padding: 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 14px;
+ margin: 0;
+ z-index: 9999999;
+ max-width: 45ch;
+ width: fit-content;
+ min-width: 27ch;
+ }
+
+ :host([data-show="true"]) {
+ display: block;
+ }
+
+ svg {
+ vertical-align: bottom;
+ margin-right: 4px;
+ }
+
+ hr {
+ border: 1px solid rgba(136, 58, 234, 0.33);
+ padding: 0;
+ margin: 0;
+ }
+
+ section {
+ padding: 8px;
+ }
+
+ .modal-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .modal-main-title {
+ font-weight: bold;
+ }
+
+ .modal-title + div {
+ margin-top: 8px;
+ }
+
+ .modal-cta {
+ display: block;
+ font-weight: bold;
+ font-size: 0.9em;
+ }
+
+ .clickable-section {
+ background: rgba(113, 24, 226, 1);
+ padding: 8px;
+ border: 0;
+ color: white;
+ font-family: system-ui, sans-serif;
+ text-align: left;
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ margin: 0;
+ width: 100%;
+ }
+
+ .clickable-section:hover {
+ cursor: pointer;
+ }
+
+ code {
+ background: rgba(136, 58, 234, 0.33);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ border-radius: 2px;
+ font-size: 14px;
+ padding: 2px;
+ }
+ `;
+
+ const fragment = new DocumentFragment();
+ this.sections.forEach((section, index) => {
+ const sectionElement = section.clickAction
+ ? document.createElement('button')
+ : document.createElement('section');
+
+ if (section.clickAction) {
+ sectionElement.classList.add('clickable-section');
+ sectionElement.addEventListener('click', async () => {
+ await section.clickAction!();
+ });
+ }
+
+ sectionElement.innerHTML = `
+ ${
+ section.title
+ ? `<div class="modal-title"><span class="modal-main-title">
+ ${section.icon ? this.getElementForIcon(section.icon) : ''}${section.title}</span>${
+ section.inlineTitle ?? ''
+ }</div>`
+ : ''
+ }
+ ${section.content ? `<div>${section.content}</div>` : ''}
+ ${section.clickDescription ? `<span class="modal-cta">${section.clickDescription}</span>` : ''}
+ `;
+ fragment.append(sectionElement);
+
+ if (index < this.sections.length - 1) {
+ fragment.append(document.createElement('hr'));
+ }
+ });
+
+ this.shadowRoot.append(fragment);
+ }
+
+ getElementForIcon(icon: Icon | (string & NonNullable<unknown>)) {
+ let iconElement;
+ if (isDefinedIcon(icon)) {
+ iconElement = getIconElement(icon);
+ } else {
+ iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = icon;
+ }
+
+ iconElement?.style.setProperty('width', '16px');
+ iconElement?.style.setProperty('height', '16px');
+
+ return iconElement?.outerHTML ?? '';
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts
new file mode 100644
index 000000000..cc483b227
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts
@@ -0,0 +1,76 @@
+import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
+
+export class DevOverlayWindow extends HTMLElement {
+ windowTitle?: string | undefined | null;
+ windowIcon?: Icon | undefined | null;
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.windowTitle = this.getAttribute('window-title');
+ this.windowIcon = this.hasAttribute('window-icon')
+ ? (this.getAttribute('window-icon') as Icon)
+ : undefined;
+ }
+
+ async connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(0deg, #13151A, #13151A), linear-gradient(0deg, #343841, #343841);
+ border: 1px solid rgba(52, 56, 65, 1);
+ width: 640px;
+ height: 480px;
+ border-radius: 12px;
+ padding: 24px;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ color: rgba(204, 206, 216, 1);
+ position: fixed;
+ z-index: 9999999999;
+ top: 55%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+
+ h1 {
+ margin: 0;
+ font-weight: 600;
+ color: #fff;
+ }
+
+ h1 svg {
+ vertical-align: text-bottom;
+ margin-right: 8px;
+ }
+
+ hr {
+ border: 1px solid rgba(27, 30, 36, 1);
+ margin: 1em 0;
+ }
+ </style>
+
+ <h1>${this.windowIcon ? this.getElementForIcon(this.windowIcon) : ''}${this.windowTitle ?? ''}</h1>
+ <hr />
+ <slot />
+ `;
+ }
+
+ getElementForIcon(icon: Icon) {
+ if (isDefinedIcon(icon)) {
+ const iconElement = getIconElement(icon);
+ iconElement?.style.setProperty('height', '1em');
+
+ return iconElement?.outerHTML;
+ } else {
+ const iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = icon;
+
+ return iconElement.outerHTML;
+ }
+ }
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index 069c2ffe8..163fcdd04 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -1,4 +1,5 @@
import type http from 'node:http';
+import { fileURLToPath } from 'node:url';
import type {
ComponentInstance,
ManifestData,
@@ -12,7 +13,7 @@ import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
import { createRenderContext, getParamsAndProps, type SSROptions } from '../core/render/index.js';
import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js';
-import { isPage } from '../core/util.js';
+import { isPage, resolveIdToUrl } from '../core/util.js';
import { getSortedPreloadedMatches } from '../prerender/routing.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
@@ -275,6 +276,24 @@ async function getScriptsAndStyles({ pipeline, filePath }: GetScriptsAndStylesPa
props: { type: 'module', src: '/@vite/client' },
children: '',
});
+
+ if (settings.config.experimental.devOverlay) {
+ scripts.add({
+ props: {
+ type: 'module',
+ src: await resolveIdToUrl(moduleLoader, 'astro/runtime/client/dev-overlay/overlay.js'),
+ },
+ children: '',
+ });
+
+ // Additional data for the dev overlay
+ scripts.add({
+ props: {},
+ children: `window.__astro_dev_overlay__ = {root: ${JSON.stringify(
+ fileURLToPath(settings.config.root)
+ )}}`,
+ });
+ }
}
// TODO: We should allow adding generic HTML elements to the head, not just scripts
diff --git a/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts
new file mode 100644
index 000000000..5c3aabe5a
--- /dev/null
+++ b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts
@@ -0,0 +1,27 @@
+import type * as vite from 'vite';
+import type { AstroPluginOptions } from '../@types/astro.js';
+
+const VIRTUAL_MODULE_ID = 'astro:dev-overlay';
+const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
+
+export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin {
+ return {
+ name: 'astro:dev-overlay',
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return resolvedVirtualModuleId;
+ }
+ },
+ async load(id) {
+ if (id === resolvedVirtualModuleId) {
+ return `
+ export const loadDevOverlayPlugins = async () => {
+ return [${settings.devOverlayPlugins
+ .map((plugin) => `(await import('${plugin}')).default`)
+ .join(',')}];
+ };
+ `;
+ }
+ },
+ };
+}