import type { Meta, StoryObj } from 'storybook-solidjs-vite'; import { createSignal, onMount, type JSX } from 'solid-js'; import './embed'; // side effect: registers import { argTypesFor, specDescription } from '../stories/docs/element-controls'; import type { EmbedCardData } from '../primitives/embed-providers'; import { configureEmbedAllowlist } from '../primitives/embed-providers'; import type { CardEvent } from '../primitives/card-contract'; // Custom DOM element — declare the tag for JSX. declare module 'solid-js' { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { 'kc-embed': JSX.HTMLAttributes & { 'card-id'?: string; ref?: (el: HTMLElement) => void; }; } } } type EmbedEl = HTMLElement & { cardId?: string; data?: EmbedCardData }; /** A sized box the embed sits in. */ function Frame(props: { children: JSX.Element }) { return
{props.children}
; } /** Mounts a , sets `.data`, logs emitted CardEvents under the render. */ function EmbedDemo(props: { cardId: string; data: EmbedCardData }) { const [log, setLog] = createSignal([]); let el: EmbedEl | undefined; onMount(() => { if (!el) return; el.cardId = props.cardId; el.data = props.data; el.addEventListener('kc-card', (e) => { const detail = (e as CustomEvent).detail; setLog((prev) => [...prev, detail]); if (detail.kind === 'open' && detail.target === 'tab') { window.open(detail.url, '_blank', 'noopener,noreferrer'); } }); }); return (
(el = e as EmbedEl)} card-id={props.cardId} />
          {log().length === 0 ? '// emitted CardEvents appear here' : JSON.stringify(log(), null, 2)}
        
); } const YT_ENVELOPE = { type: 'embed', id: 'card-embed-1', title: 'Intro video', data: { provider: 'youtube', id: 'dQw4w9WgXcQ', title: 'Product intro', aspectRatio: '16:9' }, } satisfies { type: string; id: string; title: string; data: EmbedCardData }; const HTML_SNIPPET = ` `; const meta = { title: 'Generative UI/Cards/kc-embed', tags: ['autodocs'], argTypes: argTypesFor('kc-embed'), parameters: { layout: 'padded', docs: { description: specDescription('kc-embed', [ '`` is a **privacy-first lazy media embed** (YouTube / Vimeo / allowlisted generic player) for the generative-UI feature. It speaks the **Card Contract**: data down (an `embed` `CardEnvelope`), events up (only the `open` verb, plus lifecycle `ready` / failure `error`).', '**Lazy facade:** the initial render is just a **poster + play button** — NO provider iframe, NO third-party JS, NO cookies until the user clicks play. YouTube loads via `youtube-nocookie.com`; Vimeo with `dnt=1`. This buys privacy (no tracking until opt-in) and performance (no player JS on load).', '**Security:** `generic` embeds frame an arbitrary https URL, so they are **rejected unless the app allowlists their origin** with `configureEmbedAllowlist([...])` (defaults to empty — an agent cannot frame an arbitrary origin). The player iframe is sandboxed for a *trusted provider* (`allow-scripts allow-same-origin` on a cross-origin player) — contrast ``, which trusts nothing.', '**Never a dead end:** a persistent "Open on {provider}" affordance dispatches the `open` verb (so a provider that refuses framing still has a way out). Set `data` as a JS property; `card-id` via attribute.', 'See the **Code** tab for the `CardEnvelope` JSON + HTML wiring.', ]), }, }, } satisfies Meta; export default meta; type Story = StoryObj; /** YouTube (lazy) — poster + play; the iframe loads (youtube-nocookie) only on click. `ready` fires on mount; `open` fires if the "Open on YouTube" link is used. */ export const YouTube: Story = { name: 'YouTube (lazy)', render: () => , parameters: { docs: { source: { code: HTML_SNIPPET, language: 'html' } } }, }; /** Vimeo — supply a `poster` (Vimeo has no static thumbnail URL). */ export const Vimeo: Story = { render: () => ( ), }; /** Generic player — an https embed URL whose origin the app has allowlisted. */ export const Generic: Story = { name: 'Generic player', render: () => { configureEmbedAllowlist(['https://www.youtube-nocookie.com']); return ( ); }, parameters: { docs: { source: { code: `import { configureEmbedAllowlist } from '@kitn.ai/chat'; // Generic embeds are blocked by default — allowlist the trusted origin first: configureEmbedAllowlist(['https://your-player.example.com']); em.data = { provider: 'generic', url: 'https://your-player.example.com/embed/abc', title: 'Generic embed', poster: 'https://your-cdn.example.com/poster.jpg', };`, language: 'ts', }, }, }, }; /** Custom aspect ratio — a vertical 9:16 short. */ export const CustomAspectRatio: Story = { name: 'Custom aspect ratio (9:16)', render: () => (
), }; /** Blocked-embed fallback — the "Open on provider" affordance is always present. */ export const BlockedFallback: Story = { name: 'Blocked-embed fallback', render: () => , parameters: { docs: { description: { story: 'Some providers refuse framing (X-Frame-Options / CSP `frame-ancestors`). We can\'t detect that cross-origin in JS, so the **"Open on {provider}"** affordance is *always* available — a blocked embed is never a dead end.', }, }, }, }; // A frame-less variant so the 9:16 story controls its own width. function EmbedRaw(props: { cardId: string; data: EmbedCardData }) { let el: EmbedEl | undefined; onMount(() => { if (el) { el.cardId = props.cardId; el.data = props.data; } }); return (el = e as EmbedEl)} />; }