Ver Fonte

feat(render): real Image src + Pressable, self-contained Lightbox & Modal

- Image: optional `src` renders a real <img> (object-cover), placeholder when absent
- Pressable: clickable container emitting a press event
- Lightbox: self-contained gallery + fullscreen overlay (own useState; prev/next/Esc/backdrop) — no setState action
- Modal: self-contained trigger button + centered popup rendering children (own useState; Esc/backdrop/x)
- catalog + playground rules steer generation toward Lightbox/Modal and real picsum images instead of broken Dialog+setState

Session-Id: 481d7b9c

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
root há 3 semanas atrás
pai
commit
c9b6cdae5d

+ 21 - 1
apps/web/app/api/generate/route.ts

@@ -1,5 +1,18 @@
 import { streamText } from "ai";
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
 import { headers } from "next/headers";
+
+// Route generation through the ai.mm.mk fleet gateway (OpenAI-compatible)
+// instead of the default Vercel AI Gateway.
+const gateway = createOpenAICompatible({
+  name: "ai-mm-mk",
+  baseURL: process.env.AI_API_URL || "https://ai.mm.mk/v1",
+  apiKey: process.env.AI_API_KEY || "sk-ai-mm-mk-2026",
+  headers: {
+    "X-AI-Caller":
+      "site=apps/web/app/api/generate/route.ts; comp=script; via=jsonrender-playground",
+  },
+});
 import type { Spec, EditMode } from "@json-render/core";
 import {
   buildUserPrompt,
@@ -24,6 +37,13 @@ const PLAYGROUND_RULES = [
   "NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.",
   "For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
   "For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.",
+  "For Metric, the value must be the RAW number/text only — NEVER include the currency symbol or unit inside value. Put symbols in prefix/suffix instead. Correct: value:'48,320', prefix:'$'. WRONG: value:'$48,320', prefix:'$' (renders a double '$$'). Same for percentages and units — use suffix:'%', not a '%' inside value.",
+  "For a small, fixed set of tags/badges/chips (skills, categories, labels — up to ~8 items), emit one explicit Badge element PER item inside a horizontal Stack (direction:'horizontal', gap:'sm', wrap:true). Do NOT use repeat for these short static lists.",
+  'CRITICAL repeat semantics: the top-level \'repeat\' field renders an element\'s CHILDREN once per state-array item — it must be placed on a CONTAINER (Stack/Grid) that has a child template element, and the child reads item fields via {"$item":"field"}. NEVER put \'repeat\' directly on a leaf element (Badge, Text, Metric) with {"$item":...} in its own props — a leaf has no children to repeat, so it renders NOTHING.',
+  "For Kanban boards or any side-by-side COLUMN layout: the board root must be a horizontal Stack with wrap:false (so columns stay on one row and scroll horizontally instead of stacking vertically). Give EACH column a Card with maxWidth:'sm' (or a Stack of fixed width) so columns keep a readable width. Do NOT use a vertical Stack or a wrapping horizontal Stack for board columns.",
+  "For a photo gallery WITH a lightbox (click-to-enlarge), ALWAYS use the single self-contained Lightbox component — pass images:[{src, caption}] with real picsum.photos URLs (different seed per photo). It renders the thumbnail grid AND the fullscreen overlay with prev/next internally. Do NOT build galleries from Pressable+Dialog+setState for the lightbox — that interaction does not work; Lightbox is the correct component.",
+  "For plain images (non-gallery), give Image a real 'src' (https://picsum.photos/seed/<unique-word>/<w>/<h>, different seed each) — never leave them as empty placeholders.",
+  "For any click-to-open modal (confirmation, detail popup, modal form), ALWAYS use the self-contained Modal component. The Modal element ITSELF is the trigger button (its text = triggerLabel) — place the Modal where you want that button and put the modal body as its children. Do NOT also add a separate Button to open it, and do NOT attach setState/openPath/on.press anywhere for the modal — that pattern does not work and creates a duplicate dead button. One Modal node renders both the button and its popup.",
 ];
 
 const MAX_PROMPT_LENGTH = 500;
@@ -106,7 +126,7 @@ export async function POST(req: Request) {
       });
 
   const result = streamText({
-    model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
+    model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
     system: [
       {
         role: "system",

+ 102 - 6
apps/web/lib/render/catalog.ts

@@ -35,9 +35,11 @@ export const playgroundCatalog = defineCatalog(schema, {
         justify: z
           .enum(["start", "center", "end", "between", "around"])
           .nullable(),
+        wrap: z.boolean().nullable(),
       }),
       slots: ["default"],
-      description: "Flex container for layouts",
+      description:
+        "Flex container for layouts. Horizontal stacks wrap by default; set wrap:false to keep children on a single row that scrolls horizontally — use this for Kanban boards and column layouts where columns must sit side by side.",
       example: { direction: "vertical", gap: "md" },
     },
 
@@ -47,7 +49,8 @@ export const playgroundCatalog = defineCatalog(schema, {
         gap: z.enum(["sm", "md", "lg"]).nullable(),
       }),
       slots: ["default"],
-      description: "Grid layout (1-6 columns)",
+      description:
+        "Grid layout (1-7 columns). Use columns:7 for weekly/monthly calendar grids (one column per weekday).",
       example: { columns: 3, gap: "md" },
     },
 
@@ -71,7 +74,7 @@ export const playgroundCatalog = defineCatalog(schema, {
       }),
       events: ["change"],
       description:
-        "Tab navigation. Use { $bindState } on value for active tab binding.",
+        "Tab navigation with panels. Provide one child element per tab, in the SAME order as the tabs array — children map positionally (children[0] is the panel for tabs[0], etc.). The matching child is shown when its tab is active. Use { $bindState } on value for active-tab binding.",
     },
 
     Accordion: {
@@ -88,6 +91,31 @@ export const playgroundCatalog = defineCatalog(schema, {
         "Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
     },
 
+    Timeline: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string(),
+            description: z.string().nullable(),
+            date: z.string().nullable(),
+            status: z.enum(["completed", "current", "upcoming"]).nullable(),
+          }),
+        ),
+      }),
+      description:
+        "Vertical timeline showing ordered events, steps, or historical milestones. Items as [{title, description, date, status}]. status: 'completed' (green dot), 'current' (blue dot), 'upcoming' (gray dot).",
+      example: {
+        items: [
+          {
+            title: "Project Kickoff",
+            description: "Team aligned on goals",
+            date: "Jan 2026",
+            status: "completed",
+          },
+        ],
+      },
+    },
+
     Collapsible: {
       props: z.object({
         title: z.string(),
@@ -173,22 +201,90 @@ export const playgroundCatalog = defineCatalog(schema, {
     Image: {
       props: z.object({
         alt: z.string(),
+        src: z.string().nullable(),
         width: z.number().nullable(),
         height: z.number().nullable(),
       }),
-      description: "Placeholder image (displays alt text in a styled box)",
+      description:
+        "Image. If 'src' (a URL) is set, renders the real image (object-cover); otherwise a placeholder box with the alt text. For realistic demo photos use Lorem Picsum: https://picsum.photos/seed/<unique-word>/<width>/<height> (e.g. https://picsum.photos/seed/mountains/600/400) — vary the seed per image for different photos.",
+    },
+
+    Map: {
+      props: z.object({
+        query: z.string(),
+        zoom: z.number().nullable(),
+        height: z.number().nullable(),
+      }),
+      description:
+        "Embedded interactive map centered on a location. 'query' is an address or place name (e.g. '350 5th Ave, New York, NY' or 'Eiffel Tower, Paris'). Optional zoom (1-20, default 14) and height in px (default 320). Use for contact pages, store locators, event venues.",
+      example: {
+        query: "350 5th Ave, New York, NY 10118",
+        zoom: 15,
+        height: 320,
+      },
+    },
+
+    Pressable: {
+      props: z.object({}),
+      events: ["press"],
+      slots: ["default"],
+      description:
+        "Clickable container that wraps any content (children) and emits a press event — bind on.press to a setState action. Use for clickable cards, list rows, and image thumbnails (e.g. a photo-gallery lightbox: a Pressable around a thumbnail Image that opens a Dialog).",
+    },
+
+    Lightbox: {
+      props: z.object({
+        images: z.array(
+          z.object({
+            src: z.string(),
+            caption: z.string().nullable(),
+          }),
+        ),
+        columns: z.number().nullable(),
+      }),
+      description:
+        "Self-contained photo gallery with a built-in fullscreen lightbox. Provide images as [{src, caption}] using real photo URLs (https://picsum.photos/seed/<unique-word>/600/600, different seed per image). Renders a thumbnail grid (columns 2-6, default 3); clicking a thumbnail opens a fullscreen overlay with the large photo, caption, prev/next arrows, counter, and close (Esc / click-outside / ✕). Manages its own open state internally — no setState, openPath, or Dialog needed. This is the CORRECT way to build an image gallery with a lightbox.",
+      example: {
+        columns: 3,
+        images: [
+          {
+            src: "https://picsum.photos/seed/alps/600/600",
+            caption: "The Alps",
+          },
+        ],
+      },
+    },
+
+    Modal: {
+      props: z.object({
+        triggerLabel: z.string(),
+        triggerVariant: z
+          .enum(["primary", "secondary", "outline", "danger"])
+          .nullable(),
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+        size: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description:
+        "Self-contained modal. The Modal element ITSELF renders the clickable trigger button (text = triggerLabel, styled by triggerVariant) and IS that button in the layout — place it exactly where the trigger should appear. Clicking it opens a centered overlay containing this element's children, with an optional title/description header and close (Esc / click-outside / ✕). It manages its own open state. CRITICAL: do NOT add a separate Button to open it, and do NOT attach any setState / openPath / on.press action — the Modal needs none of that. One Modal element = one trigger button + its popup. Use this (NOT Dialog + setState) for every click-to-open modal: confirmations, detail popups, modal forms. Put the modal body as this element's children.",
+      example: {
+        triggerLabel: "Open details",
+        title: "Details",
+        size: "md",
+      },
     },
 
     Icon: {
       props: z.object({
         name: z.string(),
-        size: z.enum(["sm", "md", "lg"]).nullable(),
+        size: z.enum(["sm", "md", "lg", "xl", "2xl", "3xl"]).nullable(),
         color: z
           .enum(["default", "muted", "primary", "success", "warning", "danger"])
           .nullable(),
       }),
       description:
-        "Lucide icon by name. PascalCase: MapPin, Mail, Globe, Calendar, Star, Heart, Check, X, ArrowRight, Phone, Building, Clock, Shield, Zap, Users, Eye, Download, Upload, Search, Filter, Settings, Bell, ChevronRight, ExternalLink, Info, AlertTriangle, CheckCircle, XCircle. Use in horizontal Stacks with Text for icon+label patterns. Never use emoji — always use Icon.",
+        "Lucide icon by name. PascalCase: MapPin, Mail, Globe, Calendar, Star, Heart, Check, X, ArrowRight, Phone, Building, Clock, Shield, Zap, Users, Eye, Download, Upload, Search, Filter, Settings, Bell, ChevronRight, ExternalLink, Info, AlertTriangle, CheckCircle, XCircle. Sizes sm/md/lg are inline (16-24px); xl/2xl/3xl (32/48/64px) are for hero illustrations, empty states, and 404 pages. Use in horizontal Stacks with Text for icon+label patterns. Never use emoji — always use Icon.",
     },
 
     Avatar: {

+ 351 - 40
apps/web/lib/render/registry.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState } from "react";
+import { Children, useEffect, useState } from "react";
 import {
   defineRegistry,
   useBoundProp,
@@ -96,6 +96,7 @@ import {
   Tabs as TabsPrimitive,
   TabsList,
   TabsTrigger,
+  TabsContent,
 } from "@/components/ui/tabs";
 import { Toggle } from "@/components/ui/toggle";
 import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
@@ -199,9 +200,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
                 ? "justify-around"
                 : "";
 
+      // Horizontal stacks wrap by default; wrap:false keeps a single scrolling
+      // row (Kanban boards / column layouts that must sit side by side).
+      const horizontalFlow =
+        props.wrap === false
+          ? "flex-row flex-nowrap overflow-x-auto w-full [&>*]:shrink-0"
+          : "flex-row flex-wrap";
+
       return (
         <div
-          className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
+          className={`flex ${isHorizontal ? horizontalFlow : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
         >
           {children}
         </div>
@@ -216,17 +224,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
           : 0;
       const n = Math.min(props.columns ?? 1, childCount || 1);
       const cols =
-        n >= 6
-          ? "grid-cols-6"
-          : n >= 5
-            ? "grid-cols-5"
-            : n >= 4
-              ? "grid-cols-4"
-              : n >= 3
-                ? "grid-cols-3"
-                : n >= 2
-                  ? "grid-cols-2"
-                  : "grid-cols-1";
+        n >= 7
+          ? "grid-cols-7"
+          : n >= 6
+            ? "grid-cols-6"
+            : n >= 5
+              ? "grid-cols-5"
+              : n >= 4
+                ? "grid-cols-4"
+                : n >= 3
+                  ? "grid-cols-3"
+                  : n >= 2
+                    ? "grid-cols-2"
+                    : "grid-cols-1";
       const gridGap =
         props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
 
@@ -242,7 +252,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       />
     ),
 
-    Tabs: ({ props, bindings, emit }) => {
+    Tabs: ({ props, bindings, emit, children }) => {
       const tabs = props.tabs ?? [];
       const [boundValue, setBoundValue] = useBoundProp<string>(
         props.value as string | undefined,
@@ -255,6 +265,9 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
       const setValue = isBound ? setBoundValue : setLocalValue;
 
+      // Children map positionally to tabs: children[i] is the panel for tabs[i].
+      const panels = Children.toArray(children);
+
       return (
         <TabsPrimitive
           value={value}
@@ -270,6 +283,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               </TabsTrigger>
             ))}
           </TabsList>
+          {tabs.map((tab, i) => (
+            <TabsContent key={tab.value} value={tab.value} className="pt-4">
+              {panels[i] ?? null}
+            </TabsContent>
+          ))}
         </TabsPrimitive>
       );
     },
@@ -469,38 +487,293 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       return <p className={textClass}>{props.text}</p>;
     },
 
-    Image: ({ props }) => (
-      <div
-        className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
-        style={{
-          maxWidth: props.width ?? undefined,
-          height: props.height ?? 120,
-          minHeight: 80,
-        }}
-      >
-        <svg
-          width="32"
-          height="32"
-          viewBox="0 0 24 24"
-          fill="none"
-          stroke="currentColor"
-          strokeWidth="1.5"
-          strokeLinecap="round"
-          strokeLinejoin="round"
-          className="opacity-50"
+    Image: ({ props }) => {
+      const src = typeof props.src === "string" ? props.src.trim() : "";
+      if (src) {
+        return (
+          // eslint-disable-next-line @next/next/no-img-element
+          <img
+            src={src}
+            alt={props.alt ?? ""}
+            loading="lazy"
+            className="w-full rounded-lg object-cover bg-muted"
+            style={{
+              maxWidth: props.width ?? undefined,
+              height: props.height ?? undefined,
+              aspectRatio:
+                props.height == null && props.width == null
+                  ? "16 / 9"
+                  : undefined,
+            }}
+          />
+        );
+      }
+      return (
+        <div
+          className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
+          style={{
+            maxWidth: props.width ?? undefined,
+            height: props.height ?? 120,
+            minHeight: 80,
+          }}
         >
-          <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
-          <circle cx="9" cy="9" r="2" />
-          <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
-        </svg>
-        {props.alt && <span className="text-xs">{props.alt}</span>}
-      </div>
+          <svg
+            width="32"
+            height="32"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            className="opacity-50"
+          >
+            <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
+            <circle cx="9" cy="9" r="2" />
+            <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
+          </svg>
+          {props.alt && <span className="text-xs">{props.alt}</span>}
+        </div>
+      );
+    },
+
+    Map: ({ props }) => {
+      const query = String(props.query ?? "");
+      const zoom = typeof props.zoom === "number" ? props.zoom : 14;
+      const height = typeof props.height === "number" ? props.height : 320;
+      const src = `https://maps.google.com/maps?q=${encodeURIComponent(
+        query,
+      )}&z=${zoom}&output=embed`;
+      return (
+        <iframe
+          title={query ? `Map: ${query}` : "Map"}
+          src={src}
+          className="w-full rounded-lg border border-border"
+          style={{ height, minHeight: 200 }}
+          loading="lazy"
+          referrerPolicy="no-referrer-when-downgrade"
+        />
+      );
+    },
+
+    Pressable: ({ children, emit }) => (
+      <button
+        type="button"
+        onClick={() => emit("press")}
+        className="block w-full text-left cursor-pointer rounded-lg transition-transform hover:scale-[1.02] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+      >
+        {children}
+      </button>
     ),
 
+    // Self-contained photo gallery + lightbox. Manages its own open/index state
+    // with React useState — does NOT depend on the json-render setState action.
+    Lightbox: ({ props }) => {
+      const images = (
+        Array.isArray(props.images) ? props.images : []
+      ) as Array<{ src: string; caption?: string | null }>;
+      const cols =
+        props.columns === 2
+          ? "grid-cols-2"
+          : props.columns === 4
+            ? "grid-cols-4"
+            : props.columns === 5
+              ? "grid-cols-5"
+              : props.columns === 6
+                ? "grid-cols-6"
+                : "grid-cols-3";
+      const [openIndex, setOpenIndex] = useState<number | null>(null);
+      const current = openIndex !== null ? images[openIndex] : undefined;
+
+      useEffect(() => {
+        if (openIndex === null) return;
+        const onKey = (e: KeyboardEvent) => {
+          if (e.key === "Escape") setOpenIndex(null);
+          if (e.key === "ArrowLeft")
+            setOpenIndex((i) =>
+              i === null ? i : (i - 1 + images.length) % images.length,
+            );
+          if (e.key === "ArrowRight")
+            setOpenIndex((i) => (i === null ? i : (i + 1) % images.length));
+        };
+        window.addEventListener("keydown", onKey);
+        return () => window.removeEventListener("keydown", onKey);
+      }, [openIndex, images.length]);
+
+      return (
+        <>
+          <div className={`grid w-full gap-3 ${cols}`}>
+            {images.map((img, i) => (
+              <button
+                key={i}
+                type="button"
+                onClick={() => setOpenIndex(i)}
+                className="group relative aspect-square overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+              >
+                {/* eslint-disable-next-line @next/next/no-img-element */}
+                <img
+                  src={img.src}
+                  alt={img.caption ?? ""}
+                  loading="lazy"
+                  className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
+                />
+              </button>
+            ))}
+          </div>
+
+          {current && (
+            <div
+              className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-sm"
+              onClick={() => setOpenIndex(null)}
+            >
+              <button
+                type="button"
+                aria-label="Close"
+                onClick={() => setOpenIndex(null)}
+                className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
+              >
+                ✕
+              </button>
+              {images.length > 1 && (
+                <button
+                  type="button"
+                  aria-label="Previous"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setOpenIndex((i) =>
+                      i === null ? i : (i - 1 + images.length) % images.length,
+                    );
+                  }}
+                  className="absolute left-4 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-2xl text-white hover:bg-white/20"
+                >
+                  ‹
+                </button>
+              )}
+              <figure
+                className="flex max-h-[90vh] max-w-[92vw] flex-col items-center gap-3"
+                onClick={(e) => e.stopPropagation()}
+              >
+                {/* eslint-disable-next-line @next/next/no-img-element */}
+                <img
+                  src={current.src}
+                  alt={current.caption ?? ""}
+                  className="max-h-[82vh] max-w-full rounded-lg object-contain shadow-2xl"
+                />
+                {current.caption && (
+                  <figcaption className="text-sm text-white/80">
+                    {current.caption}
+                    <span className="ml-2 text-white/50">
+                      {(openIndex ?? 0) + 1} / {images.length}
+                    </span>
+                  </figcaption>
+                )}
+              </figure>
+              {images.length > 1 && (
+                <button
+                  type="button"
+                  aria-label="Next"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setOpenIndex((i) =>
+                      i === null ? i : (i + 1) % images.length,
+                    );
+                  }}
+                  className="absolute right-4 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-2xl text-white hover:bg-white/20"
+                >
+                  ›
+                </button>
+              )}
+            </div>
+          )}
+        </>
+      );
+    },
+
+    // Self-contained modal: renders its own trigger button and manages open
+    // state with React useState — no setState / openPath / Dialog needed.
+    Modal: ({ props, children }) => {
+      const [open, setOpen] = useState(false);
+      const variant =
+        props.triggerVariant === "outline"
+          ? "outline"
+          : props.triggerVariant === "secondary"
+            ? "secondary"
+            : props.triggerVariant === "danger"
+              ? "destructive"
+              : "default";
+      const sizeClass =
+        props.size === "lg"
+          ? "max-w-2xl"
+          : props.size === "sm"
+            ? "max-w-sm"
+            : "max-w-lg";
+
+      useEffect(() => {
+        if (!open) return;
+        const onKey = (e: KeyboardEvent) => {
+          if (e.key === "Escape") setOpen(false);
+        };
+        window.addEventListener("keydown", onKey);
+        return () => window.removeEventListener("keydown", onKey);
+      }, [open]);
+
+      return (
+        <>
+          <Button variant={variant} onClick={() => setOpen(true)}>
+            {props.triggerLabel ?? "Open"}
+          </Button>
+          {open && (
+            <div
+              className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
+              onClick={() => setOpen(false)}
+            >
+              <div
+                role="dialog"
+                aria-modal="true"
+                className={`w-full ${sizeClass} rounded-xl border border-border bg-background p-6 shadow-2xl`}
+                onClick={(e) => e.stopPropagation()}
+              >
+                <div className="flex items-start justify-between gap-4">
+                  <div className="min-w-0">
+                    {props.title && (
+                      <h2 className="text-lg font-semibold leading-tight">
+                        {props.title}
+                      </h2>
+                    )}
+                    {props.description && (
+                      <p className="mt-1 text-sm text-muted-foreground">
+                        {props.description}
+                      </p>
+                    )}
+                  </div>
+                  <button
+                    type="button"
+                    aria-label="Close"
+                    onClick={() => setOpen(false)}
+                    className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
+                  >
+                    ✕
+                  </button>
+                </div>
+                {children && <div className="mt-4">{children}</div>}
+              </div>
+            </div>
+          )}
+        </>
+      );
+    },
+
     Icon: ({ props }) => {
       const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
       if (!IconComponent) return null;
-      const sizeMap = { sm: 16, md: 20, lg: 24 } as const;
+      const sizeMap = {
+        sm: 16,
+        md: 20,
+        lg: 24,
+        xl: 32,
+        "2xl": 48,
+        "3xl": 64,
+      } as const;
       const px = sizeMap[props.size ?? "md"] ?? 20;
       const colorClass =
         props.color === "muted"
@@ -783,6 +1056,44 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
     },
 
+    Timeline: ({ props }) => (
+      <div className="relative pl-8">
+        <div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
+        <div className="flex flex-col gap-6">
+          {(props.items ?? []).map((item, i) => {
+            const dotColor =
+              item.status === "completed"
+                ? "bg-emerald-500"
+                : item.status === "current"
+                  ? "bg-blue-500"
+                  : "bg-muted-foreground/30";
+            return (
+              <div key={i} className="relative">
+                <div
+                  className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
+                />
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2 flex-wrap">
+                    <p className="font-medium text-sm">{item.title}</p>
+                    {item.date && (
+                      <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
+                        {item.date}
+                      </span>
+                    )}
+                  </div>
+                  {item.description && (
+                    <p className="text-sm text-muted-foreground mt-1">
+                      {item.description}
+                    </p>
+                  )}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    ),
+
     Metric: ({ props }) => {
       const changeColor =
         props.changeType === "positive"