Web Preview
Browser frame with navigation, URL bar, iframe body and console.
import { WebPreview, WebPreviewBody, WebPreviewConsole, WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl,} from "@/components/ai/web-preview";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const page = `<!doctype html><html><body style="margin:0;padding:22px;font-family:system-ui,sans-serif;background:#f4f3ef;color:#1f1f1d"><div style="font-size:20px;font-weight:600">MAG4</div><div style="font-size:12px;color:#777">Magnificent Four</div><div style="display:flex;gap:10px;margin-top:16px">${[ ["NAV", "$0.9998"], ["Holders", "412"], ["AUM", "$49K"],] .map( ([k, v]) => `<div style="width:130px;padding:10px 12px;background:#fff;border:1px solid #e4e2da;border-radius:10px"><div style="font-size:12px;color:#777">${k}</div><div style="font:16px ui-monospace,monospace">${v}</div></div>`, ) .join("")}</div></body></html>`;
const logs = [ { level: "log" as const, message: "Mounted IndexPage", timestamp: new Date("2026-01-01T14:02:11"), }, { level: "warn" as const, message: "Missing key prop in list", timestamp: new Date("2026-01-01T14:02:12"), },];
function Navigation() { return ( <WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"> <IconPlaceholder lucide="ChevronLeftIcon" tabler="IconChevronLeft" hugeicons="ArrowLeft01Icon" phosphor="CaretLeftIcon" remixicon="RiArrowLeftSLine" className="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"> <IconPlaceholder lucide="ChevronRightIcon" tabler="IconChevronRight" hugeicons="ArrowRight01Icon" phosphor="CaretRightIcon" remixicon="RiArrowRightSLine" className="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"> <IconPlaceholder lucide="RotateCwIcon" tabler="IconRotateClockwise2" hugeicons="Rotate01Icon" phosphor="ArrowClockwiseIcon" remixicon="RiRefreshLine" className="size-4" /> </WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"> <IconPlaceholder lucide="ExternalLinkIcon" tabler="IconExternalLink" hugeicons="LinkSquare02Icon" phosphor="ArrowSquareOutIcon" remixicon="RiExternalLinkLine" className="size-4" /> </WebPreviewNavigationButton> </WebPreviewNavigation> );}
export default function Demo() { return ( <div className="flex w-full max-w-2xl flex-col gap-5"> <WebPreview defaultConsoleOpen defaultUrl="localhost:3000/indexes/mag4"> <Navigation /> <WebPreviewBody className="h-[220px]" srcDoc={page} /> <WebPreviewConsole logs={logs} /> </WebPreview> <WebPreview defaultUrl="localhost:3000/indexes/mag4"> <Navigation /> <WebPreviewBody className="h-[220px]" src="about:blank" loading={ <div className="absolute inset-0 flex items-center justify-center gap-2.5 bg-background text-[13.5px] text-muted-foreground"> <IconPlaceholder lucide="Loader2Icon" tabler="IconLoader" hugeicons="Loading03Icon" phosphor="SpinnerIcon" remixicon="RiLoaderLine" className="size-4 animate-spin" /> Generating preview… </div> } /> </WebPreview> </div> );}<script setup lang="ts">import { WebPreview, WebPreviewBody, WebPreviewConsole, WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl,} from "@/components/ai/web-preview";import { ChevronLeftIcon, ChevronRightIcon, ArrowUpRightIcon, Loader2Icon, RotateCwIcon } from "@lucide/vue";
const cards = [ ["NAV", "$0.9998"], ["Holders", "412"], ["AUM", "$49K"],] .map( ([k, v]) => `<div style="width:130px;padding:10px 12px;background:#fff;border:1px solid #e4e2da;border-radius:10px"><div style="font-size:12px;color:#777">${k}</div><div style="font:16px ui-monospace,monospace">${v}</div></div>`, ) .join("");
const page = `<!doctype html><html><body style="margin:0;padding:22px;font-family:system-ui,sans-serif;background:#f4f3ef;color:#1f1f1d"><div style="font-size:20px;font-weight:600">MAG4</div><div style="font-size:12px;color:#777">Magnificent Four</div><div style="display:flex;gap:10px;margin-top:16px">${cards}</div></body></html>`;
const logs = [ { level: "log" as const, message: "Mounted IndexPage", timestamp: new Date("2026-01-01T14:02:11") }, { level: "warn" as const, message: "Missing key prop in list", timestamp: new Date("2026-01-01T14:02:12") },];</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <WebPreview default-console-open default-url="localhost:3000/indexes/mag4"> <WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"><ChevronLeftIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"><ChevronRightIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"><RotateCwIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"><ArrowUpRightIcon class="size-4" /></WebPreviewNavigationButton> </WebPreviewNavigation> <WebPreviewBody class="h-[220px]" :srcdoc="page" /> <WebPreviewConsole :logs="logs" /> </WebPreview> <WebPreview default-url="localhost:3000/indexes/mag4"> <WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"><ChevronLeftIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"><ChevronRightIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"><RotateCwIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"><ArrowUpRightIcon class="size-4" /></WebPreviewNavigationButton> </WebPreviewNavigation> <WebPreviewBody class="h-[220px]" src="about:blank"> <template #loading> <div class="absolute inset-0 flex items-center justify-center gap-2.5 bg-background text-[13.5px] text-muted-foreground"> <Loader2Icon class="size-4 animate-spin" /> Generating preview… </div> </template> </WebPreviewBody> </WebPreview> </div></template><script lang="ts"> import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte"; import { WebPreview, WebPreviewBody, WebPreviewConsole, WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl, } from "$lib/components/ai/web-preview";
const cards = [ ["NAV", "$0.9998"], ["Holders", "412"], ["AUM", "$49K"], ] .map( ([k, v]) => `<div style="width:130px;padding:10px 12px;background:#fff;border:1px solid #e4e2da;border-radius:10px"><div style="font-size:12px;color:#777">${k}</div><div style="font:16px ui-monospace,monospace">${v}</div></div>` ) .join("");
const page = `<!doctype html><html><body style="margin:0;padding:22px;font-family:system-ui,sans-serif;background:#f4f3ef;color:#1f1f1d"><div style="font-size:20px;font-weight:600">MAG4</div><div style="font-size:12px;color:#777">Magnificent Four</div><div style="display:flex;gap:10px;margin-top:16px">${cards}</div></body></html>`;
const logs = [ { level: "log" as const, message: "Mounted IndexPage", timestamp: new Date("2026-01-01T14:02:11") }, { level: "warn" as const, message: "Missing key prop in list", timestamp: new Date("2026-01-01T14:02:12") }, ];</script>
{#snippet nav()} <WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"> <IconPlaceholder lucide="ChevronLeftIcon" tabler="IconChevronLeft" hugeicons="ArrowLeft01Icon" phosphor="CaretLeftIcon" remixicon="RiArrowLeftSLine" class="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"> <IconPlaceholder lucide="ChevronRightIcon" tabler="IconChevronRight" hugeicons="ArrowRight01Icon" phosphor="CaretRightIcon" remixicon="RiArrowRightSLine" class="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"> <IconPlaceholder lucide="RotateCwIcon" tabler="IconRotateClockwise2" hugeicons="Rotate01Icon" phosphor="ArrowClockwiseIcon" remixicon="RiRefreshLine" class="size-4" /> </WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"> <IconPlaceholder lucide="ExternalLinkIcon" tabler="IconExternalLink" hugeicons="LinkSquare02Icon" phosphor="ArrowSquareOutIcon" remixicon="RiExternalLinkLine" class="size-4" /> </WebPreviewNavigationButton> </WebPreviewNavigation>{/snippet}
{#snippet spinner()} <div class="absolute inset-0 flex items-center justify-center gap-2.5 bg-background text-[13.5px] text-muted-foreground" > <span class="size-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-60"></span> Generating preview… </div>{/snippet}
<div class="flex w-full max-w-2xl flex-col gap-5"> <WebPreview url="localhost:3000/indexes/mag4" consoleOpen> {@render nav()} <WebPreviewBody class="h-[220px]" srcdoc={page} /> <WebPreviewConsole {logs} /> </WebPreview> <WebPreview url="localhost:3000/indexes/mag4"> {@render nav()} <WebPreviewBody class="h-[220px]" src="about:blank" loading={spinner} /> </WebPreview></div>Built on button, input, tooltip and collapsible.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-web-previewpnpm dlx shadcn@latest add @edmi-ui/ai-web-previewyarn dlx shadcn@latest add @edmi-ui/ai-web-previewbunx shadcn@latest add @edmi-ui/ai-web-previewPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,collapsible,input,tooltip.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-web-previewpnpm dlx shadcn-vue@latest add @edmi-ui/ai-web-previewyarn dlx shadcn-vue@latest add @edmi-ui/ai-web-previewbunx shadcn-vue@latest add @edmi-ui/ai-web-previewPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vueTerminal window pnpm add @lucide/vueTerminal window yarn add @lucide/vueTerminal window bun add @lucide/vueAdd the Edmi components this one builds on:
button,collapsible,input,tooltip.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-web-preview.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-web-preview.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-web-preview.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-web-preview.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,collapsible,input,tooltip.Copy the code from Component source into your project and update the import paths to match your aliases.
import { WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl,} from "@/components/ai/web-preview";
<WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"> <CaretLeftIcon className="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"> <CaretRightIcon className="size-4" /> </WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"> <ArrowClockwiseIcon className="size-4" /> </WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"> <ArrowSquareOutIcon className="size-4" /> </WebPreviewNavigationButton></WebPreviewNavigation><script setup lang="ts">import { WebPreview, WebPreviewBody, WebPreviewConsole, WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl,} from "@/components/ai/web-preview";</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <WebPreview default-console-open default-url="localhost:3000/indexes/mag4"> <WebPreviewNavigation> <WebPreviewNavigationButton tooltip="Back"><ChevronLeftIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Forward"><ChevronRightIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewNavigationButton tooltip="Reload"><RotateCwIcon class="size-4" /></WebPreviewNavigationButton> <WebPreviewUrl /> <WebPreviewNavigationButton tooltip="Open in new tab"><ArrowUpRightIcon class="size-4" /></WebPreviewNavigationButton> </WebPreviewNavigation> <WebPreviewBody class="h-[220px]" /> <WebPreviewConsole /> </WebPreview> <!-- … --> </div></template><script lang="ts"> import { WebPreview, WebPreviewBody, WebPreviewConsole, } from "$lib/components/ai/web-preview";</script>
<div class="flex w-full max-w-2xl flex-col gap-5"> <WebPreview url="localhost:3000/indexes/mag4" consoleOpen> <WebPreviewBody class="h-[220px]" /> <WebPreviewConsole /> </WebPreview> <WebPreview url="localhost:3000/indexes/mag4"> <WebPreviewBody class="h-[220px]" src="about:blank" /> </WebPreview></div>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { createContext, useCallback, useContext, useMemo, useState,} from "react";import { LockKeyIcon, CaretDownIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/registry/edmi/ui/collapsible";import { Input } from "@/registry/edmi/ui/input";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/registry/edmi/ui/tooltip";
export interface WebPreviewContextValue { url: string; setUrl: (url: string) => void; consoleOpen: boolean; setConsoleOpen: (open: boolean) => void;}
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => { const context = useContext(WebPreviewContext); if (!context) { throw new Error("WebPreview components must be used within a WebPreview"); } return context;};
export type WebPreviewProps = ComponentProps<"div"> & { defaultUrl?: string; onUrlChange?: (url: string) => void; /** ✦ Start with the console open. */ defaultConsoleOpen?: boolean;};
export const WebPreview = ({ className, children, defaultUrl = "", onUrlChange, defaultConsoleOpen = false, ...props}: WebPreviewProps) => { const [url, setUrl] = useState(defaultUrl); const [consoleOpen, setConsoleOpen] = useState(defaultConsoleOpen);
const handleUrlChange = useCallback( (newUrl: string) => { setUrl(newUrl); onUrlChange?.(newUrl); }, [onUrlChange], );
const contextValue = useMemo<WebPreviewContextValue>( () => ({ consoleOpen, setConsoleOpen, setUrl: handleUrlChange, url, }), [consoleOpen, handleUrlChange, url], );
return ( <WebPreviewContext.Provider value={contextValue}> <div data-slot="ai-web-preview" className={cn( "flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card", className, )} {...props} > {children} </div> </WebPreviewContext.Provider> );};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({ className, children, ...props}: WebPreviewNavigationProps) => ( <div data-slot="ai-web-preview-navigation" className={cn( "flex h-11 shrink-0 items-center gap-1 border-b border-border px-2", className, )} {...props} > {children} </div>);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & { tooltip?: string;};
export const WebPreviewNavigationButton = ({ onClick, disabled, tooltip, children, ...props}: WebPreviewNavigationButtonProps) => { const button = ( <Button aria-label={tooltip} disabled={disabled} onClick={onClick} size="icon-sm" variant="ghost" {...props} > {children} </Button> );
if (!tooltip) { return button; }
return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={button} /> <TooltipContent> <p>{tooltip}</p> </TooltipContent> </Tooltip> </TooltipProvider> );};
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({ value, onChange, onKeyDown, className, ...props}: WebPreviewUrlProps) => { const { url, setUrl } = useWebPreview(); const [prevUrl, setPrevUrl] = useState(url); const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally (derived state pattern) if (url !== prevUrl) { setPrevUrl(url); setInputValue(url); }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setInputValue(event.target.value); onChange?.(event); };
const handleKeyDown = useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") { const target = event.target as HTMLInputElement; setUrl(target.value); } onKeyDown?.(event); }, [setUrl, onKeyDown], );
return ( <div className="relative mx-1.5 flex-1"> <LockKeyIcon className="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground" /> <Input data-slot="ai-web-preview-url" className={cn("h-[30px] pl-7 font-mono text-xs", className)} onChange={onChange ?? handleChange} onKeyDown={handleKeyDown} placeholder="Enter URL..." value={value ?? inputValue} {...props} /> </div> );};
export type WebPreviewBodyProps = Omit<ComponentProps<"iframe">, "loading"> & { loading?: ReactNode;};
export const WebPreviewBody = ({ className, loading, src, ...props}: WebPreviewBodyProps) => { const { url } = useWebPreview();
return ( <div data-slot="ai-web-preview-body" className="relative min-h-0 flex-1 bg-background" > <iframe className={cn("size-full", className)} sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" src={(src ?? url) || undefined} title="Preview" {...props} /> {loading} </div> );};
export type WebPreviewConsoleProps = ComponentProps<"div"> & { logs?: { level: "log" | "warn" | "error"; message: string; timestamp: Date; }[];};
export const WebPreviewConsole = ({ className, logs = [], children, ...props}: WebPreviewConsoleProps) => { const { consoleOpen, setConsoleOpen } = useWebPreview();
return ( <Collapsible data-slot="ai-web-preview-console" className={cn( "border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]", className, )} onOpenChange={setConsoleOpen} open={consoleOpen} {...props} > <CollapsibleTrigger className="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"> Console <CaretDownIcon className="size-3.5 transition-transform duration-200 group-data-[panel-open]/ai-console:rotate-180" /> </CollapsibleTrigger> <CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0"> <div className="max-h-48 overflow-y-auto px-3 pb-2"> {logs.length === 0 ? ( <p className="text-muted-foreground">No console output</p> ) : ( logs.map((log) => ( <div className={cn( log.level === "error" && "text-destructive-text", log.level === "warn" && "text-warning-text", log.level === "log" && "text-foreground", )} key={`${log.timestamp.getTime()}-${log.level}-${log.message}`} > <span className="text-muted-foreground"> {log.timestamp.toLocaleTimeString([], { hour12: false })} </span>{" "} {log.level.padEnd(4, " ")} {log.message} </div> )) )} {children} </div> </CollapsibleContent> </Collapsible> );};<script setup lang="ts">import type { HTMLAttributes } from "vue"import { ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { provideWebPreviewContext } from "./context"
const props = withDefaults(defineProps<{ defaultUrl?: string /** ✦ Start with the console open. */ defaultConsoleOpen?: boolean class?: HTMLAttributes["class"]}>(), { defaultUrl: "", defaultConsoleOpen: false,})
const emit = defineEmits<{ (e: "update:url", value: string): void (e: "urlChange", value: string): void (e: "update:consoleOpen", value: boolean): void (e: "consoleOpenChange", value: boolean): void}>()
const url = ref(props.defaultUrl)const consoleOpen = ref(props.defaultConsoleOpen)
function setUrl(value: string) { url.value = value emit("update:url", value) emit("urlChange", value)}
function setConsoleOpen(value: boolean) { consoleOpen.value = value emit("update:consoleOpen", value) emit("consoleOpenChange", value)}
provideWebPreviewContext({ url, setUrl, consoleOpen, setConsoleOpen,})</script>
<template> <div data-slot="ai-web-preview" :class="cn('flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card', props.class)" > <slot /> </div></template><script setup lang="ts">import type { IframeHTMLAttributes, VNodeChild } from "vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useWebPreviewContext } from "./context"
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: IframeHTMLAttributes["class"] src?: string}>()
defineSlots<{ loading?: () => VNodeChild}>()
const { url } = useWebPreviewContext()
const frameSrc = computed(() => (props.src ?? url.value) || undefined)</script>
<template> <div data-slot="ai-web-preview-body" class="relative min-h-0 flex-1 bg-background"> <iframe :class="cn('size-full', props.class)" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" :src="frameSrc" title="Preview" v-bind="$attrs" /> <slot name="loading" /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { ChevronDownIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"import { useWebPreviewContext } from "./context"
type LogLevel = "log" | "warn" | "error"
interface ConsoleLog { level: LogLevel message: string timestamp: Date}
const props = withDefaults(defineProps<{ logs?: ConsoleLog[] class?: HTMLAttributes["class"]}>(), { logs: () => [],})
const context = useWebPreviewContext()
function levelClass(level: LogLevel) { if (level === "error") return "text-destructive-text" if (level === "warn") return "text-warning-text" return "text-foreground"}</script>
<template> <Collapsible data-slot="ai-web-preview-console" :class="cn('border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]', props.class)" :open="context.consoleOpen.value" @update:open="context.setConsoleOpen" > <CollapsibleTrigger class="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"> Console <ChevronDownIcon class="size-3.5 transition-transform duration-200 group-data-[state=open]/ai-console:rotate-180" /> </CollapsibleTrigger> <CollapsibleContent class="overflow-hidden"> <div class="max-h-48 overflow-y-auto px-3 pb-2"> <p v-if="!props.logs.length" class="text-muted-foreground"> No console output </p> <template v-else> <div v-for="(log, index) in props.logs" :key="`${log.timestamp.getTime?.() ?? index}-${index}`" :class="levelClass(log.level)" > <span class="text-muted-foreground">{{ log.timestamp.toLocaleTimeString([], { hour12: false }) }}</span> {{ " " }}{{ log.level.padEnd(4, " ") }} {{ log.message }} </div> </template> <slot /> </div> </CollapsibleContent> </Collapsible></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div data-slot="ai-web-preview-navigation" :class="cn('flex h-11 shrink-0 items-center gap-1 border-b border-border px-2', props.class)" > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Button } from "@/registry/edmi/ui/button"import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/registry/edmi/ui/tooltip"
const props = withDefaults(defineProps<{ size?: ButtonVariants["size"] variant?: ButtonVariants["variant"] tooltip?: string disabled?: boolean class?: HTMLAttributes["class"]}>(), { tooltip: "", disabled: false, size: "icon-sm", variant: "ghost",})</script>
<template> <TooltipProvider v-if="props.tooltip"> <Tooltip> <TooltipTrigger as-child> <Button type="button" :aria-label="props.tooltip" :variant="props.variant" :size="props.size" :disabled="props.disabled" :class="props.class" > <slot /> </Button> </TooltipTrigger> <TooltipContent> <p>{{ props.tooltip }}</p> </TooltipContent> </Tooltip> </TooltipProvider> <Button v-else type="button" :aria-label="props.tooltip || undefined" :variant="props.variant" :size="props.size" :disabled="props.disabled" :class="props.class" > <slot /> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { LockIcon } from "@lucide/vue"import { ref, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Input } from "@/registry/edmi/ui/input"import { useWebPreviewContext } from "./context"
const props = withDefaults(defineProps<{ placeholder?: string class?: HTMLAttributes["class"]}>(), { placeholder: "Enter URL...",})
const context = useWebPreviewContext()
const inputValue = ref(context.url.value)
watch( () => context.url.value, (value) => { inputValue.value = value }, { immediate: true },)
function handleKeydown() { context.setUrl(String(inputValue.value))}</script>
<template> <div class="relative mx-1.5 flex-1"> <LockIcon class="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground" /> <Input v-model="inputValue" data-slot="ai-web-preview-url" :class="cn('h-[30px] pl-7 font-mono text-xs', props.class)" :placeholder="props.placeholder" @keydown.enter="handleKeydown" /> </div></template>import type { InjectionKey, Ref } from "vue";import { inject, provide } from "vue";
export interface WebPreviewContextValue { url: Ref<string>; setUrl: (url: string) => void; consoleOpen: Ref<boolean>; setConsoleOpen: (open: boolean) => void;}
const WebPreviewContextKey: InjectionKey<WebPreviewContextValue> = Symbol("WebPreviewContext");
export function provideWebPreviewContext(value: WebPreviewContextValue) { provide(WebPreviewContextKey, value);}
export function useWebPreviewContext() { const context = inject(WebPreviewContextKey, null);
if (!context) { throw new Error("WebPreview components must be used within WebPreview"); }
return context;}export { default as WebPreview } from "./WebPreview.vue";export { default as WebPreviewBody } from "./WebPreviewBody.vue";export { default as WebPreviewConsole } from "./WebPreviewConsole.vue";export { default as WebPreviewNavigation } from "./WebPreviewNavigation.vue";export { default as WebPreviewNavigationButton } from "./WebPreviewNavigationButton.vue";export { default as WebPreviewUrl } from "./WebPreviewUrl.vue";import WebPreview from "./web-preview.svelte";import WebPreviewBody from "./web-preview-body.svelte";import WebPreviewConsole from "./web-preview-console.svelte";import WebPreviewNavigation from "./web-preview-navigation.svelte";import WebPreviewNavigationButton from "./web-preview-navigation-button.svelte";import WebPreviewUrl from "./web-preview-url.svelte";
export { WebPreview, WebPreviewBody, WebPreviewConsole, WebPreviewNavigation, WebPreviewNavigationButton, WebPreviewUrl,};import { getContext, setContext } from "svelte";
export interface WebPreviewContext { url: string; consoleOpen: boolean;}
const KEY = Symbol("ai-web-preview");
export function setWebPreviewContext(ctx: WebPreviewContext) { setContext(KEY, ctx);}
export function useWebPreviewContext(): WebPreviewContext { const ctx = getContext<WebPreviewContext | undefined>(KEY); if (!ctx) throw new Error("WebPreview components must be used within WebPreview"); return ctx;}<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLIframeAttributes } from "svelte/elements"; import { useWebPreviewContext } from "./use-web-preview.svelte.js";
let { class: className, src, loading, ...restProps }: HTMLIframeAttributes & { /** Rendered over the frame, e.g. a spinner while the preview is generated. */ loading?: Snippet; } = $props();
const preview = useWebPreviewContext(); const frameSrc = $derived((src ?? preview.url) || undefined);</script>
<div data-slot="ai-web-preview-body" class="relative min-h-0 flex-1 bg-background"> <iframe class={cn("size-full", className)} sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" src={frameSrc} title="Preview" {...restProps} ></iframe> {@render loading?.()}</div><script lang="ts"> import CaretDownIcon from "phosphor-svelte/lib/CaretDown"; import * as Collapsible from "$lib/registry/ui/collapsible/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import { useWebPreviewContext } from "./use-web-preview.svelte.js";
type LogLevel = "log" | "warn" | "error";
let { logs = [], class: className, children, }: { logs?: { level: LogLevel; message: string; timestamp: Date }[]; class?: string; children?: Snippet; } = $props();
const preview = useWebPreviewContext();
function levelClass(level: LogLevel) { if (level === "error") return "text-destructive-text"; if (level === "warn") return "text-warning-text"; return "text-foreground"; }</script>
<Collapsible.Root open={preview.consoleOpen} onOpenChange={(value) => (preview.consoleOpen = value)} data-slot="ai-web-preview-console" class={cn("border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]", className)}> <Collapsible.Trigger class="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > Console <CaretDownIcon class="size-3.5 transition-transform duration-200 group-data-[state=open]/ai-console:rotate-180" /> </Collapsible.Trigger> <Collapsible.Content class="overflow-hidden"> <div class="max-h-48 overflow-y-auto px-3 pb-2"> {#if logs.length === 0} <p class="text-muted-foreground">No console output</p> {:else} {#each logs as log, index (`${log.timestamp.getTime()}-${index}`)} <div class={levelClass(log.level)}> <span class="text-muted-foreground" >{log.timestamp.toLocaleTimeString([], { hour12: false })}</span > {log.level.padEnd(4, " ")} {log.message} </div> {/each} {/if} {@render children?.()} </div> </Collapsible.Content></Collapsible.Root><script lang="ts"> import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import * as Tooltip from "$lib/registry/ui/tooltip/index.js";
let { tooltip, variant = "ghost", size = "icon-sm", children, ...restProps }: Omit<ButtonProps, "href"> & { tooltip?: string } = $props();</script>
{#if tooltip} <Tooltip.Provider> <Tooltip.Root> <Tooltip.Trigger> {#snippet child({ props })} <Button {...props} aria-label={tooltip} {...restProps} {size} type="button" {variant}> {@render children?.()} </Button> {/snippet} </Tooltip.Trigger> <Tooltip.Content> <p>{tooltip}</p> </Tooltip.Content> </Tooltip.Root> </Tooltip.Provider>{:else} <Button {...restProps} {size} type="button" {variant}> {@render children?.()} </Button>{/if}<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();</script>
<div bind:this={ref} data-slot="ai-web-preview-navigation" class={cn("flex h-11 shrink-0 items-center gap-1 border-b border-border px-2", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import LockKeyIcon from "phosphor-svelte/lib/LockKey"; import { Input } from "$lib/registry/ui/input/index.js"; import { cn } from "$lib/utils.js"; import type { HTMLInputAttributes } from "svelte/elements"; import { useWebPreviewContext } from "./use-web-preview.svelte.js";
let { class: className, placeholder = "Enter URL...", ...restProps }: Omit<HTMLInputAttributes, "value" | "type"> = $props();
const preview = useWebPreviewContext(); let inputValue = $state(preview.url);
$effect(() => { inputValue = preview.url; });
function onkeydown(event: KeyboardEvent) { if (event.key === "Enter") preview.url = inputValue; }</script>
<div class="relative mx-1.5 flex-1"> <LockKeyIcon class="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground" /> <Input data-slot="ai-web-preview-url" class={cn("h-[30px] pl-7 font-mono text-xs", className)} {placeholder} bind:value={inputValue} {onkeydown} {...(restProps as Record<string, unknown>)} /></div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { setWebPreviewContext } from "./use-web-preview.svelte.js";
let { ref = $bindable(null), url = $bindable(""), consoleOpen = $bindable(false), onUrlChange, class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { /** Current URL (`bind:url`); the initial value is the default URL. */ url?: string; /** ✦ Console state (`bind:consoleOpen`); start `true` to open it. */ consoleOpen?: boolean; onUrlChange?: (url: string) => void; } = $props();
setWebPreviewContext({ get url() { return url; }, set url(value: string) { url = value; onUrlChange?.(value); }, get consoleOpen() { return consoleOpen; }, set consoleOpen(value: boolean) { consoleOpen = value; }, });</script>
<div bind:this={ref} data-slot="ai-web-preview" class={cn("flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card", className)} {...restProps}> {@render children?.()}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| WebPreview | defaultUrl, onUrlChange, defaultConsoleOpen |
string, (url) => void, boolean |
"", false |
| WebPreviewNavigationButton | tooltip |
string |
|
| WebPreviewBody | loading |
ReactNode |
|
| WebPreviewConsole | logs |
{ level, message, timestamp }[] |
[] |
| WebPreviewNavigation, Url |
✦ Edmi additions
Section titled “✦ Edmi additions”- The URL field is mono with a lock icon; navigation buttons are
ghosticon buttons (open in new tab and fullscreen are yours to add as buttons). defaultConsoleOpenstarts the console expanded.- The body is a sandboxed
iframe; passloadingto cover it while the preview is generated.