summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-25 07:05:04 -0500
committerAdam <[email protected]>2026-03-25 09:14:35 -0500
commitb480a38d313416f7020f61f8bfbe4df920fd90d4 (patch)
tree78e98d7ee011a9a95724eae4501fc9e73c6cbb7b
parent4167e25c7ec53d066fc81cb15c7ac490569be073 (diff)
downloadopencode-b480a38d313416f7020f61f8bfbe4df920fd90d4.tar.gz
opencode-b480a38d313416f7020f61f8bfbe4df920fd90d4.zip
chore(app): markdown playground in storyboard
-rw-r--r--packages/ui/src/components/message-part.css5
-rw-r--r--packages/ui/src/components/session-turn.css4
-rw-r--r--packages/ui/src/components/timeline-playground.stories.tsx1579
3 files changed, 1579 insertions, 9 deletions
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index aa685392a..fbde8ee7c 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -248,11 +248,6 @@
opacity: 1;
pointer-events: auto;
}
-
- [data-component="markdown"] {
- margin-top: 0;
- font-size: var(--font-size-base);
- }
}
[data-component="compaction-part"] {
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 26d918050..8075d2577 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -85,10 +85,6 @@
flex-direction: column;
align-self: stretch;
gap: 12px;
-
- > :first-child > [data-component="markdown"]:first-child {
- margin-top: 0;
- }
}
[data-slot="session-turn-diffs"] {
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx
new file mode 100644
index 000000000..16bf2591a
--- /dev/null
+++ b/packages/ui/src/components/timeline-playground.stories.tsx
@@ -0,0 +1,1579 @@
+// @ts-nocheck
+import { createSignal, createMemo, For, Show, Index, batch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import type {
+ Message,
+ UserMessage,
+ AssistantMessage,
+ Part,
+ TextPart,
+ ReasoningPart,
+ ToolPart,
+ CompactionPart,
+ FilePart,
+ AgentPart,
+} from "@opencode-ai/sdk/v2"
+import { DataProvider } from "../context/data"
+import { FileComponentProvider } from "../context/file"
+import { SessionTurn } from "./session-turn"
+
+// ---------------------------------------------------------------------------
+// ID helpers
+// ---------------------------------------------------------------------------
+let seq = 0
+const uid = () => `pg-${++seq}-${Date.now().toString(36)}`
+
+// ---------------------------------------------------------------------------
+// Lorem ipsum content
+// ---------------------------------------------------------------------------
+const LOREM = [
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
+ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+ "Cras justo odio, dapibus ut facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper.",
+]
+
+// ---------------------------------------------------------------------------
+// User message variants
+// ---------------------------------------------------------------------------
+const USER_VARIANTS = {
+ short: {
+ label: "short",
+ text: "Fix the bug in the login form",
+ parts: [] as Part[],
+ },
+ medium: {
+ label: "medium",
+ text: "Can you update the session timeline component to support lazy loading? The current implementation loads everything eagerly which causes jank on large sessions.",
+ parts: [] as Part[],
+ },
+ long: {
+ label: "long",
+ text: `I need you to refactor the message rendering pipeline. Currently the timeline renders all messages synchronously which blocks first paint. Here's what I want:
+
+1. Implement virtual scrolling for the message list
+2. Defer-mount older messages using requestAnimationFrame batching
+3. Add content-visibility: auto to each turn container
+4. Make sure the scroll-to-bottom behavior still works correctly after these changes
+
+Please also add appropriate CSS containment hints and make sure we don't break the sticky header behavior for the session title.`,
+ parts: [] as Part[],
+ },
+ "with @file": {
+ label: "with @file",
+ text: "Update @src/components/session-turn.tsx to fix the spacing issue between parts",
+ parts: (() => {
+ const id = `static-file-${Date.now()}`
+ return [
+ {
+ id,
+ type: "file",
+ mime: "text/plain",
+ filename: "session-turn.tsx",
+ url: "src/components/session-turn.tsx",
+ source: {
+ type: "file",
+ path: "src/components/session-turn.tsx",
+ text: {
+ value: "@src/components/session-turn.tsx",
+ start: 7,
+ end: 38,
+ },
+ },
+ } as FilePart,
+ ]
+ })(),
+ },
+ "with @agent": {
+ label: "with @agent",
+ text: "Use @explore to find all CSS files related to the timeline, then fix the spacing",
+ parts: (() => {
+ return [
+ {
+ id: `static-agent-${Date.now()}`,
+ type: "agent",
+ name: "explore",
+ source: { start: 4, end: 12 },
+ } as AgentPart,
+ ]
+ })(),
+ },
+ "with image": {
+ label: "with image",
+ text: "Here's a screenshot of the bug I'm seeing",
+ parts: (() => {
+ // 1x1 blue pixel PNG as data URI for a realistic attachment
+ const pixel =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+ return [
+ {
+ id: `static-img-${Date.now()}`,
+ type: "file",
+ mime: "image/png",
+ filename: "screenshot.png",
+ url: pixel,
+ } as FilePart,
+ ]
+ })(),
+ },
+ "with file attachment": {
+ label: "with file attachment",
+ text: "Check this config file for issues",
+ parts: (() => {
+ return [
+ {
+ id: `static-attach-${Date.now()}`,
+ type: "file",
+ mime: "application/json",
+ filename: "tsconfig.json",
+ url: "data:application/json;base64,e30=",
+ } as FilePart,
+ ]
+ })(),
+ },
+ "multi attachment": {
+ label: "multi attachment",
+ text: "Look at these files and the screenshot, then fix the layout",
+ parts: (() => {
+ const pixel =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+ return [
+ {
+ id: `static-multi-img-${Date.now()}`,
+ type: "file",
+ mime: "image/png",
+ filename: "layout-bug.png",
+ url: pixel,
+ } as FilePart,
+ {
+ id: `static-multi-file-${Date.now()}`,
+ type: "file",
+ mime: "text/css",
+ filename: "session-turn.css",
+ url: "data:text/css;base64,LyogZW1wdHkgKi8=",
+ } as FilePart,
+ {
+ id: `static-multi-ref-${Date.now()}`,
+ type: "file",
+ mime: "text/plain",
+ filename: "session-turn.tsx",
+ url: "src/components/session-turn.tsx",
+ source: {
+ type: "file",
+ path: "src/components/session-turn.tsx",
+ text: { value: "@src/components/session-turn.tsx", start: 0, end: 0 },
+ },
+ } as FilePart,
+ ]
+ })(),
+ },
+} satisfies Record<string, { label: string; text: string; parts: Part[] }>
+
+const MARKDOWN_SAMPLES = {
+ headings: `# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+
+Some paragraph text after headings.`,
+
+ lists: `Here's a list of changes:
+
+- First item with some explanation
+- Second item that is a bit longer and wraps to the next line when the viewport is narrow
+- Third item
+ - Nested item A
+ - Nested item B
+
+1. Numbered first
+2. Numbered second
+3. Numbered third`,
+
+ code: `Here's an inline \`variable\` reference and a code block:
+
+\`\`\`typescript
+export function sum(values: number[]) {
+ return values.reduce((total, value) => total + value, 0)
+}
+
+export function average(values: number[]) {
+ if (values.length === 0) return 0
+ return sum(values) / values.length
+}
+\`\`\`
+
+And some text after the code block.`,
+
+ mixed: `## Implementation Plan
+
+I'll make the following changes:
+
+1. **Update the schema** - Add new fields to the database model
+2. **Create the API endpoint** - Handle validation and persistence
+3. **Add frontend components** - Build the form and display views
+
+Here's the key change:
+
+\`\`\`typescript
+const table = sqliteTable("session", {
+ id: text().primaryKey(),
+ project_id: text().notNull(),
+ created_at: integer().notNull(),
+})
+\`\`\`
+
+> Note: This is a breaking change that requires a migration.
+
+The migration will handle existing data by setting \`project_id\` to the default workspace.
+
+---
+
+For more details, see the [documentation](https://example.com/docs).`,
+
+ table: `## Comparison
+
+| Feature | Before | After |
+|---------|--------|-------|
+| Speed | 120ms | 45ms |
+| Memory | 256MB | 128MB |
+| Bundle | 1.2MB | 890KB |
+
+The improvements are significant across all metrics.`,
+
+ blockquote: `## Summary
+
+> This is a blockquote that contains important information about the implementation approach.
+>
+> It spans multiple lines and contains **bold** and \`code\` elements.
+
+The approach above was chosen for its simplicity.`,
+
+ links: `Check out these resources:
+
+- [SolidJS docs](https://solidjs.com)
+- [TypeScript handbook](https://www.typescriptlang.org/docs/handbook)
+- The API is at \`https://api.example.com/v2\`
+
+You can also visit https://example.com/docs for more info.`,
+
+ images: `## Screenshot
+
+Here's what the output looks like:
+
+![Alt text](https://via.placeholder.com/400x200)
+
+And below is the final result.`,
+}
+
+const REASONING_SAMPLES = [
+ `**Analyzing the request**
+
+The user wants to add a new feature to the session timeline. I need to understand the existing component structure first.
+
+Let me look at the key files involved:
+- \`session-turn.tsx\` handles individual turns
+- \`message-part.tsx\` renders different part types
+- The data flows through the \`DataProvider\` context`,
+
+ `**Considering approaches**
+
+I could either modify the existing SessionTurn component or create a wrapper. The wrapper approach is cleaner because it doesn't touch the core rendering logic.
+
+The trade-off is that we'd need to pass additional props through, but that's acceptable for this use case.`,
+
+ `**Planning the implementation**
+
+I'll need to:
+1. Create the data generators
+2. Wire up the context providers
+3. Add CSS variable controls
+4. Implement the export functionality
+
+This should be straightforward given the existing component architecture.`,
+]
+
+const TOOL_SAMPLES = {
+ read: {
+ tool: "read",
+ input: { filePath: "src/components/session-turn.tsx", offset: 1, limit: 50 },
+ output: "export function SessionTurn(props) {\n // component implementation\n return <div>...</div>\n}",
+ title: "Read src/components/session-turn.tsx",
+ metadata: {},
+ },
+ glob: {
+ tool: "glob",
+ input: { pattern: "**/*.tsx", path: "src/components" },
+ output: "src/components/button.tsx\nsrc/components/card.tsx\nsrc/components/session-turn.tsx",
+ title: "Found 3 files",
+ metadata: {},
+ },
+ grep: {
+ tool: "grep",
+ input: { pattern: "SessionTurn", path: "src", include: "*.tsx" },
+ output: "src/components/session-turn.tsx:141\nsrc/pages/session/timeline.tsx:987",
+ title: "Found 2 matches",
+ metadata: {},
+ },
+ bash: {
+ tool: "bash",
+ input: { command: "bun test --filter session", description: "Run session tests" },
+ output:
+ "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
+ title: "Run session tests",
+ metadata: { command: "bun test --filter session" },
+ },
+ edit: {
+ tool: "edit",
+ input: {
+ filePath: "src/components/session-turn.tsx",
+ oldString: "gap: 12px",
+ newString: "gap: 18px",
+ },
+ output: "File edited successfully",
+ title: "Edit src/components/session-turn.tsx",
+ metadata: {
+ filediff: {
+ file: "src/components/session-turn.tsx",
+ before: " gap: 12px;\n display: flex;",
+ after: " gap: 18px;\n display: flex;",
+ additions: 1,
+ deletions: 1,
+ },
+ },
+ },
+ write: {
+ tool: "write",
+ input: {
+ filePath: "src/utils/helpers.ts",
+ content:
+ "export function clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max)\n}\n",
+ },
+ output: "File written successfully",
+ title: "Write src/utils/helpers.ts",
+ metadata: {},
+ },
+ task: {
+ tool: "task",
+ input: { description: "Explore components", subagent_type: "explore", prompt: "Find all session components" },
+ output: "Found 12 session-related components across 3 directories.",
+ title: "Agent (Explore)",
+ metadata: { sessionId: "sub-session-1" },
+ },
+ webfetch: {
+ tool: "webfetch",
+ input: { url: "https://solidjs.com/docs/latest/api" },
+ output: "# SolidJS API Reference\n\nCore primitives for building reactive applications...",
+ title: "Fetch https://solidjs.com/docs/latest/api",
+ metadata: {},
+ },
+ websearch: {
+ tool: "websearch",
+ input: { query: "SolidJS createStore performance" },
+ output:
+ "https://solidjs.com/docs/latest/api#createstore\nhttps://dev.to/solidjs/understanding-solid-reactivity\nhttps://github.com/solidjs/solid/discussions/1234",
+ title: "Search: SolidJS createStore performance",
+ metadata: {},
+ },
+ question: {
+ tool: "question",
+ input: {
+ questions: [
+ {
+ question: "Which approach do you prefer?",
+ header: "Approach",
+ options: [
+ { label: "Wrapper component", description: "Create a new wrapper around SessionTurn" },
+ { label: "Direct modification", description: "Modify SessionTurn directly" },
+ ],
+ },
+ ],
+ },
+ output: "",
+ title: "Question",
+ metadata: { answers: [["Wrapper component"]] },
+ },
+ skill: {
+ tool: "skill",
+ input: { name: "playwriter" },
+ output: "Skill loaded successfully",
+ title: "playwriter",
+ metadata: {},
+ },
+ todowrite: {
+ tool: "todowrite",
+ input: {
+ todos: [
+ { content: "Create data generators", status: "completed", priority: "high" },
+ { content: "Build UI controls", status: "in_progress", priority: "high" },
+ { content: "Add CSS export", status: "pending", priority: "medium" },
+ ],
+ },
+ output: "",
+ title: "Todos",
+ metadata: {
+ todos: [
+ { content: "Create data generators", status: "completed", priority: "high" },
+ { content: "Build UI controls", status: "in_progress", priority: "high" },
+ { content: "Add CSS export", status: "pending", priority: "medium" },
+ ],
+ },
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Fake data generators
+// ---------------------------------------------------------------------------
+const SESSION_ID = "playground-session"
+
+function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } {
+ const id = uid()
+ return {
+ message: {
+ id,
+ sessionID: SESSION_ID,
+ role: "user",
+ time: { created: Date.now() },
+ agent: "code",
+ model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+ } as UserMessage,
+ parts: [
+ { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart,
+ // Clone extra parts with fresh ids so each user message owns unique part instances
+ ...extra.map((p) => ({ ...p, id: uid() })),
+ ],
+ }
+}
+
+function mkAssistant(parentID: string): AssistantMessage {
+ return {
+ id: uid(),
+ sessionID: SESSION_ID,
+ role: "assistant",
+ time: { created: Date.now(), completed: Date.now() + 3000 },
+ parentID,
+ modelID: "claude-sonnet-4-20250514",
+ providerID: "anthropic",
+ mode: "default",
+ agent: "code",
+ path: { cwd: "/project", root: "/project" },
+ cost: 0.003,
+ tokens: { input: 1200, output: 800, reasoning: 200, cache: { read: 0, write: 0 } },
+ } as AssistantMessage
+}
+
+function textPart(text: string): TextPart {
+ return { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart
+}
+
+function reasoningPart(text: string): ReasoningPart {
+ return { id: uid(), type: "reasoning", text, time: { start: Date.now(), end: Date.now() + 500 } } as ReasoningPart
+}
+
+function toolPart(sample: (typeof TOOL_SAMPLES)[keyof typeof TOOL_SAMPLES], status = "completed"): ToolPart {
+ const base = {
+ id: uid(),
+ type: "tool" as const,
+ callID: uid(),
+ tool: sample.tool,
+ }
+ if (status === "completed") {
+ return {
+ ...base,
+ state: {
+ status: "completed",
+ input: sample.input,
+ output: sample.output,
+ title: sample.title,
+ metadata: sample.metadata ?? {},
+ time: { start: Date.now(), end: Date.now() + 1000 },
+ },
+ } as ToolPart
+ }
+ if (status === "running") {
+ return {
+ ...base,
+ state: {
+ status: "running",
+ input: sample.input,
+ title: sample.title,
+ metadata: sample.metadata ?? {},
+ time: { start: Date.now() },
+ },
+ } as ToolPart
+ }
+ return {
+ ...base,
+ state: { status: "pending", input: sample.input, raw: "" },
+ } as ToolPart
+}
+
+function compactionPart(): CompactionPart {
+ return { id: uid(), type: "compaction", auto: true } as CompactionPart
+}
+
+// ---------------------------------------------------------------------------
+// CSS Controls definition
+// ---------------------------------------------------------------------------
+type CSSControl = {
+ key: string
+ label: string
+ group: string
+ type: "range" | "color" | "select"
+ initial: string
+ selector: string
+ property: string
+ min?: string
+ max?: string
+ step?: string
+ options?: string[]
+ unit?: string
+}
+
+const CSS_CONTROLS: CSSControl[] = [
+ // --- Timeline spacing ---
+ {
+ key: "turn-gap",
+ label: "Turn gap",
+ group: "Timeline Spacing",
+ type: "range",
+ initial: "12",
+ selector: '[role="log"]',
+ property: "gap",
+ min: "0",
+ max: "80",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "container-gap",
+ label: "Container gap",
+ group: "Timeline Spacing",
+ type: "range",
+ initial: "18",
+ selector: '[data-slot="session-turn-message-container"]',
+ property: "gap",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "assistant-gap",
+ label: "Assistant parts gap",
+ group: "Timeline Spacing",
+ type: "range",
+ initial: "12",
+ selector: '[data-slot="session-turn-assistant-content"]',
+ property: "gap",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "text-part-margin",
+ label: "Text part margin-top",
+ group: "Timeline Spacing",
+ type: "range",
+ initial: "24",
+ selector: '[data-component="text-part"]',
+ property: "margin-top",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown typography ---
+ {
+ key: "md-font-size",
+ label: "Font size",
+ group: "Markdown Typography",
+ type: "range",
+ initial: "14",
+ selector: '[data-component="markdown"]',
+ property: "font-size",
+ min: "10",
+ max: "22",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-line-height",
+ label: "Line height",
+ group: "Markdown Typography",
+ type: "range",
+ initial: "180",
+ selector: '[data-component="markdown"]',
+ property: "line-height",
+ min: "100",
+ max: "300",
+ step: "5",
+ unit: "%",
+ },
+
+ // --- Markdown headings ---
+ {
+ key: "md-heading-margin-top",
+ label: "Heading margin-top",
+ group: "Markdown Headings",
+ type: "range",
+ initial: "32",
+ selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+ property: "margin-top",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-heading-margin-bottom",
+ label: "Heading margin-bottom",
+ group: "Markdown Headings",
+ type: "range",
+ initial: "12",
+ selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+ property: "margin-bottom",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-heading-font-size",
+ label: "Heading font size",
+ group: "Markdown Headings",
+ type: "range",
+ initial: "14",
+ selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+ property: "font-size",
+ min: "12",
+ max: "28",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown paragraphs ---
+ {
+ key: "md-p-margin-bottom",
+ label: "Paragraph margin-bottom",
+ group: "Markdown Paragraphs",
+ type: "range",
+ initial: "16",
+ selector: '[data-component="markdown"] p',
+ property: "margin-bottom",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown lists ---
+ {
+ key: "md-list-margin-top",
+ label: "List margin-top",
+ group: "Markdown Lists",
+ type: "range",
+ initial: "8",
+ selector: '[data-component="markdown"] :is(ul,ol)',
+ property: "margin-top",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-list-margin-bottom",
+ label: "List margin-bottom",
+ group: "Markdown Lists",
+ type: "range",
+ initial: "16",
+ selector: '[data-component="markdown"] :is(ul,ol)',
+ property: "margin-bottom",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-list-padding-left",
+ label: "List padding-left",
+ group: "Markdown Lists",
+ type: "range",
+ initial: "24",
+ selector: '[data-component="markdown"] :is(ul,ol)',
+ property: "padding-left",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-li-margin-bottom",
+ label: "List item margin-bottom",
+ group: "Markdown Lists",
+ type: "range",
+ initial: "8",
+ selector: '[data-component="markdown"] li',
+ property: "margin-bottom",
+ min: "0",
+ max: "20",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown code blocks ---
+ {
+ key: "md-pre-margin-top",
+ label: "Code block margin-top",
+ group: "Markdown Code",
+ type: "range",
+ initial: "32",
+ selector: '[data-component="markdown"] pre',
+ property: "margin-top",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-pre-margin-bottom",
+ label: "Code block margin-bottom",
+ group: "Markdown Code",
+ type: "range",
+ initial: "32",
+ selector: '[data-component="markdown"] pre',
+ property: "margin-bottom",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-shiki-font-size",
+ label: "Code font size",
+ group: "Markdown Code",
+ type: "range",
+ initial: "13",
+ selector: '[data-component="markdown"] .shiki',
+ property: "font-size",
+ min: "10",
+ max: "20",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-shiki-padding",
+ label: "Code padding",
+ group: "Markdown Code",
+ type: "range",
+ initial: "12",
+ selector: '[data-component="markdown"] .shiki',
+ property: "padding",
+ min: "0",
+ max: "32",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-shiki-radius",
+ label: "Code border-radius",
+ group: "Markdown Code",
+ type: "range",
+ initial: "6",
+ selector: '[data-component="markdown"] .shiki',
+ property: "border-radius",
+ min: "0",
+ max: "16",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown blockquotes ---
+ {
+ key: "md-blockquote-margin",
+ label: "Blockquote margin",
+ group: "Markdown Blockquotes",
+ type: "range",
+ initial: "24",
+ selector: '[data-component="markdown"] blockquote',
+ property: "margin-block",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-blockquote-padding-left",
+ label: "Blockquote padding-left",
+ group: "Markdown Blockquotes",
+ type: "range",
+ initial: "8",
+ selector: '[data-component="markdown"] blockquote',
+ property: "padding-left",
+ min: "0",
+ max: "40",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-blockquote-border-width",
+ label: "Blockquote border width",
+ group: "Markdown Blockquotes",
+ type: "range",
+ initial: "2",
+ selector: '[data-component="markdown"] blockquote',
+ property: "border-left-width",
+ min: "0",
+ max: "8",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown tables ---
+ {
+ key: "md-table-margin",
+ label: "Table margin",
+ group: "Markdown Tables",
+ type: "range",
+ initial: "24",
+ selector: '[data-component="markdown"] table',
+ property: "margin-block",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "md-td-padding",
+ label: "Cell padding",
+ group: "Markdown Tables",
+ type: "range",
+ initial: "12",
+ selector: '[data-component="markdown"] :is(th,td)',
+ property: "padding",
+ min: "0",
+ max: "24",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Markdown HR ---
+ {
+ key: "md-hr-margin",
+ label: "HR margin",
+ group: "Markdown HR",
+ type: "range",
+ initial: "40",
+ selector: '[data-component="markdown"] hr',
+ property: "margin-block",
+ min: "0",
+ max: "80",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Reasoning part ---
+ {
+ key: "reasoning-md-margin-top",
+ label: "Reasoning markdown margin-top",
+ group: "Reasoning Part",
+ type: "range",
+ initial: "24",
+ selector: '[data-component="reasoning-part"] [data-component="markdown"]',
+ property: "margin-top",
+ min: "0",
+ max: "60",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- User message ---
+ {
+ key: "user-msg-padding",
+ label: "User bubble padding",
+ group: "User Message",
+ type: "range",
+ initial: "12",
+ selector: '[data-slot="user-message-text"]',
+ property: "padding",
+ min: "0",
+ max: "32",
+ step: "1",
+ unit: "px",
+ },
+ {
+ key: "user-msg-radius",
+ label: "User bubble border-radius",
+ group: "User Message",
+ type: "range",
+ initial: "6",
+ selector: '[data-slot="user-message-text"]',
+ property: "border-radius",
+ min: "0",
+ max: "24",
+ step: "1",
+ unit: "px",
+ },
+
+ // --- Tool parts ---
+ {
+ key: "bash-max-height",
+ label: "Shell output max-height",
+ group: "Tool Parts",
+ type: "range",
+ initial: "240",
+ selector: '[data-slot="bash-scroll"]',
+ property: "max-height",
+ min: "100",
+ max: "600",
+ step: "10",
+ unit: "px",
+ },
+]
+
+// ---------------------------------------------------------------------------
+// Playground component
+// ---------------------------------------------------------------------------
+function FileStub() {
+ return <div style={{ padding: "8px", color: "var(--text-weak)", "font-size": "13px" }}>File viewer stub</div>
+}
+
+function Playground() {
+ // ---- Messages & parts state ----
+ const [state, setState] = createStore<{
+ messages: Message[]
+ parts: Record<string, Part[]>
+ }>({
+ messages: [],
+ parts: {},
+ })
+
+ // ---- CSS overrides ----
+ const [css, setCss] = createStore<Record<string, string>>({})
+ let styleEl: HTMLStyleElement | undefined
+
+ const updateStyle = () => {
+ const rules: string[] = []
+ for (const ctrl of CSS_CONTROLS) {
+ const val = css[ctrl.key]
+ if (val === undefined) continue
+ const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+ rules.push(`${ctrl.selector} { ${ctrl.property}: ${value} !important; }`)
+ }
+ if (styleEl) styleEl.textContent = rules.join("\n")
+ }
+
+ const setCssValue = (key: string, value: string) => {
+ setCss(key, value)
+ updateStyle()
+ }
+
+ const resetCss = () => {
+ batch(() => {
+ for (const ctrl of CSS_CONTROLS) {
+ setCss(ctrl.key, undefined as any)
+ }
+ })
+ if (styleEl) styleEl.textContent = ""
+ }
+
+ // ---- Derived ----
+ const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
+
+ const data = createMemo(() => ({
+ session: [{ id: SESSION_ID }],
+ session_status: {},
+ session_diff: {},
+ message: { [SESSION_ID]: state.messages },
+ part: state.parts,
+ provider: {
+ all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
+ },
+ }))
+
+ // ---- Find or create the last assistant message to append parts to ----
+ const lastAssistantID = createMemo(() => {
+ for (let i = state.messages.length - 1; i >= 0; i--) {
+ if (state.messages[i].role === "assistant") return state.messages[i].id
+ }
+ return undefined
+ })
+
+ /** Ensure a turn (user + assistant) exists and return the assistant message id */
+ const ensureTurn = (): string => {
+ const id = lastAssistantID()
+ if (id) return id
+ // Create a minimal placeholder turn
+ const user = mkUser("...")
+ const asst = mkAssistant(user.message.id)
+ setState(
+ produce((draft) => {
+ draft.messages.push(user.message)
+ draft.messages.push(asst)
+ draft.parts[user.message.id] = user.parts
+ draft.parts[asst.id] = []
+ }),
+ )
+ return asst.id
+ }
+
+ /** Append parts to the last assistant message */
+ const appendParts = (parts: Part[]) => {
+ const id = ensureTurn()
+ setState(
+ produce((draft) => {
+ const existing = draft.parts[id] ?? []
+ draft.parts[id] = [...existing, ...parts]
+ }),
+ )
+ }
+
+ // ---- User message helpers ----
+ const addUser = (variant: keyof typeof USER_VARIANTS) => {
+ const v = USER_VARIANTS[variant]
+ const user = mkUser(v.text, v.parts)
+ const asst = mkAssistant(user.message.id)
+ setState(
+ produce((draft) => {
+ draft.messages.push(user.message)
+ draft.messages.push(asst)
+ draft.parts[user.message.id] = user.parts
+ draft.parts[asst.id] = []
+ }),
+ )
+ }
+
+ // ---- Part helpers (append to last turn) ----
+ const addText = (variant: keyof typeof MARKDOWN_SAMPLES) => {
+ appendParts([textPart(MARKDOWN_SAMPLES[variant])])
+ }
+
+ const addReasoning = () => {
+ const idx = Math.floor(Math.random() * REASONING_SAMPLES.length)
+ appendParts([reasoningPart(REASONING_SAMPLES[idx])])
+ }
+
+ const addTool = (name: keyof typeof TOOL_SAMPLES) => {
+ appendParts([toolPart(TOOL_SAMPLES[name])])
+ }
+
+ // ---- Composite helpers (create full turns with user + assistant) ----
+ const addFullTurn = (userText: string, parts: Part[]) => {
+ const user = mkUser(userText)
+ const asst = mkAssistant(user.message.id)
+ setState(
+ produce((draft) => {
+ draft.messages.push(user.message)
+ draft.messages.push(asst)
+ draft.parts[user.message.id] = user.parts
+ draft.parts[asst.id] = parts
+ }),
+ )
+ }
+
+ const addContextGroupTurn = () => {
+ addFullTurn("Read some files", [
+ toolPart(TOOL_SAMPLES.read),
+ toolPart(TOOL_SAMPLES.glob),
+ toolPart(TOOL_SAMPLES.grep),
+ textPart("After gathering context, here's what I found:\n\n" + LOREM[2]),
+ ])
+ }
+
+ const addReasoningFullTurn = () => {
+ addFullTurn("Make the changes described above", [
+ reasoningPart(REASONING_SAMPLES[0]),
+ toolPart(TOOL_SAMPLES.read),
+ toolPart(TOOL_SAMPLES.glob),
+ toolPart(TOOL_SAMPLES.grep),
+ toolPart(TOOL_SAMPLES.edit),
+ toolPart(TOOL_SAMPLES.bash),
+ textPart(MARKDOWN_SAMPLES.mixed),
+ ])
+ }
+
+ const addKitchenSink = () => {
+ // User message variants
+ addUser("short")
+ appendParts([textPart(MARKDOWN_SAMPLES.headings)])
+ addUser("medium")
+ appendParts([textPart(MARKDOWN_SAMPLES.lists)])
+ addUser("long")
+ appendParts([textPart(MARKDOWN_SAMPLES.code)])
+ addUser("with @file")
+ appendParts([textPart(MARKDOWN_SAMPLES.mixed)])
+ addUser("with image")
+ appendParts([reasoningPart(REASONING_SAMPLES[0]), textPart(MARKDOWN_SAMPLES.table)])
+ addUser("multi attachment")
+ appendParts([
+ toolPart(TOOL_SAMPLES.read),
+ toolPart(TOOL_SAMPLES.glob),
+ toolPart(TOOL_SAMPLES.grep),
+ toolPart(TOOL_SAMPLES.edit),
+ toolPart(TOOL_SAMPLES.bash),
+ textPart(MARKDOWN_SAMPLES.blockquote),
+ ])
+ addContextGroupTurn()
+ addReasoningFullTurn()
+ }
+
+ const clearAll = () => {
+ setState({ messages: [], parts: {} })
+ seq = 0
+ }
+
+ // ---- CSS export ----
+ const exportCss = () => {
+ const lines: string[] = ["/* Timeline Playground CSS Overrides */", ""]
+ const groups = new Map<string, string[]>()
+
+ for (const ctrl of CSS_CONTROLS) {
+ const val = css[ctrl.key]
+ if (val === undefined) continue
+ const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+ const group = ctrl.group
+ if (!groups.has(group)) groups.set(group, [])
+ groups.get(group)!.push(`/* ${ctrl.label}: ${value} */`)
+ groups.get(group)!.push(`${ctrl.selector} { ${ctrl.property}: ${value}; }`)
+ }
+
+ if (groups.size === 0) {
+ lines.push("/* No overrides applied */")
+ } else {
+ for (const [group, rules] of groups) {
+ lines.push(`/* --- ${group} --- */`)
+ lines.push(...rules)
+ lines.push("")
+ }
+ }
+
+ const text = lines.join("\n")
+ navigator.clipboard.writeText(text).catch(() => {})
+ return text
+ }
+
+ const [exported, setExported] = createSignal("")
+
+ // ---- Panel collapse state ----
+ const [panels, setPanels] = createStore({
+ generators: true,
+ css: true,
+ export: false,
+ })
+
+ // ---- Group collapse state for CSS ----
+ const [collapsed, setCollapsed] = createStore<Record<string, boolean>>({})
+ const groups = createMemo(() => {
+ const result = new Map<string, CSSControl[]>()
+ for (const ctrl of CSS_CONTROLS) {
+ if (!result.has(ctrl.group)) result.set(ctrl.group, [])
+ result.get(ctrl.group)!.push(ctrl)
+ }
+ return result
+ })
+
+ // ---- Shared button styles ----
+ const sectionLabel = {
+ "font-size": "11px",
+ color: "var(--text-weak)",
+ "margin-bottom": "4px",
+ "text-transform": "uppercase",
+ "letter-spacing": "0.5px",
+ } as const
+ const btnStyle = {
+ padding: "4px 8px",
+ "border-radius": "4px",
+ border: "1px solid var(--border-weak-base)",
+ background: "var(--surface-base)",
+ cursor: "pointer",
+ "font-size": "12px",
+ color: "var(--text-base)",
+ } as const
+ const btnAccent = {
+ ...btnStyle,
+ border: "1px solid var(--border-interactive-base)",
+ background: "var(--surface-interactive-weak)",
+ "font-weight": "500",
+ color: "var(--text-interactive-base)",
+ } as const
+ const btnDanger = {
+ ...btnStyle,
+ border: "1px solid var(--border-critical-base)",
+ background: "transparent",
+ color: "var(--text-on-critical-base)",
+ } as const
+
+ return (
+ <div style={{ display: "flex", height: "calc(100vh - 48px)", gap: "0", overflow: "hidden", margin: "-24px" }}>
+ {/* Inject dynamic style element */}
+ <style ref={styleEl!} />
+
+ {/* Left sidebar: controls */}
+ <div
+ style={{
+ width: "320px",
+ "min-width": "320px",
+ "border-right": "1px solid var(--border-weak-base)",
+ overflow: "auto",
+ "background-color": "var(--background-stronger)",
+ "scrollbar-width": "none",
+ }}
+ >
+ {/* Generate section */}
+ <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+ <button
+ style={{
+ width: "100%",
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "space-between",
+ padding: "10px 12px",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ "font-weight": "500",
+ "font-size": "13px",
+ color: "var(--text-strong)",
+ }}
+ onClick={() => setPanels("generators", (v) => !v)}
+ >
+ Generate Messages
+ <span>{panels.generators ? "−" : "+"}</span>
+ </button>
+ <Show when={panels.generators}>
+ <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
+ {/* ---- User messages ---- */}
+ <div style={sectionLabel}>User messages</div>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+ Creates a new turn (user + empty assistant)
+ </div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <For each={Object.keys(USER_VARIANTS) as (keyof typeof USER_VARIANTS)[]}>
+ {(key) => (
+ <button style={btnStyle} onClick={() => addUser(key)}>
+ {USER_VARIANTS[key].label}
+ </button>
+ )}
+ </For>
+ </div>
+
+ {/* ---- Text and reasoning blocks ---- */}
+ <div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+ Appends to the last turn's assistant parts
+ </div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <For each={Object.keys(MARKDOWN_SAMPLES) as (keyof typeof MARKDOWN_SAMPLES)[]}>
+ {(key) => (
+ <button style={btnStyle} onClick={() => addText(key)}>
+ {key}
+ </button>
+ )}
+ </For>
+ <button style={btnStyle} onClick={addReasoning}>
+ reasoning
+ </button>
+ </div>
+
+ {/* ---- Tool calls ---- */}
+ <div style={{ ...sectionLabel, "margin-top": "8px" }}>Tool calls</div>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+ Appends to the last turn's assistant parts
+ </div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <For each={Object.keys(TOOL_SAMPLES) as (keyof typeof TOOL_SAMPLES)[]}>
+ {(key) => (
+ <button style={btnStyle} onClick={() => addTool(key)}>
+ {key}
+ </button>
+ )}
+ </For>
+ </div>
+
+ {/* ---- Composite (full turns) ---- */}
+ <div style={{ ...sectionLabel, "margin-top": "8px" }}>Composite turns</div>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+ Creates complete user + assistant turns
+ </div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <button style={btnStyle} onClick={addContextGroupTurn}>
+ context group
+ </button>
+ <button style={btnStyle} onClick={addReasoningFullTurn}>
+ full turn
+ </button>
+ <button style={btnAccent} onClick={addKitchenSink}>
+ kitchen sink
+ </button>
+ </div>
+
+ <div style={{ "margin-top": "8px" }}>
+ <button style={btnDanger} onClick={clearAll}>
+ Clear all
+ </button>
+ </div>
+ </div>
+ </Show>
+ </div>
+
+ {/* CSS Controls section */}
+ <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+ <button
+ style={{
+ width: "100%",
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "space-between",
+ padding: "10px 12px",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ "font-weight": "500",
+ "font-size": "13px",
+ color: "var(--text-strong)",
+ }}
+ onClick={() => setPanels("css", (v) => !v)}
+ >
+ CSS Controls
+ <span>{panels.css ? "−" : "+"}</span>
+ </button>
+ <Show when={panels.css}>
+ <div style={{ padding: "0 12px 12px" }}>
+ <button
+ style={{
+ padding: "4px 8px",
+ "border-radius": "4px",
+ border: "1px solid var(--border-weak-base)",
+ background: "var(--surface-base)",
+ cursor: "pointer",
+ "font-size": "11px",
+ color: "var(--text-base)",
+ "margin-bottom": "8px",
+ }}
+ onClick={resetCss}
+ >
+ Reset all
+ </button>
+
+ <For each={[...groups().entries()]}>
+ {([group, controls]) => (
+ <div style={{ "margin-bottom": "4px" }}>
+ <button
+ style={{
+ width: "100%",
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "space-between",
+ padding: "6px 0",
+ background: "none",
+ border: "none",
+ "border-bottom": "1px solid var(--border-weaker-base)",
+ cursor: "pointer",
+ "font-size": "11px",
+ "font-weight": "500",
+ color: "var(--text-base)",
+ "text-transform": "uppercase",
+ "letter-spacing": "0.5px",
+ }}
+ onClick={() => setCollapsed(group, (v) => !v)}
+ >
+ {group}
+ <span style={{ "font-size": "10px" }}>{collapsed[group] ? "+" : "−"}</span>
+ </button>
+ <Show when={!collapsed[group]}>
+ <div style={{ padding: "6px 0", display: "flex", "flex-direction": "column", gap: "8px" }}>
+ <For each={controls}>
+ {(ctrl) => (
+ <div style={{ display: "flex", "flex-direction": "column", gap: "2px" }}>
+ <div
+ style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}
+ >
+ <label
+ style={{
+ "font-size": "11px",
+ color: "var(--text-base)",
+ }}
+ >
+ {ctrl.label}
+ </label>
+ <span
+ style={{
+ "font-size": "11px",
+ color:
+ css[ctrl.key] !== undefined ? "var(--text-interactive-base)" : "var(--text-weak)",
+ "font-family": "var(--font-family-mono)",
+ "min-width": "40px",
+ "text-align": "right",
+ }}
+ >
+ {css[ctrl.key] ?? ctrl.initial}
+ {ctrl.unit ?? ""}
+ </span>
+ </div>
+ <input
+ type="range"
+ min={ctrl.min ?? "0"}
+ max={ctrl.max ?? "100"}
+ step={ctrl.step ?? "1"}
+ value={css[ctrl.key] ?? ctrl.initial}
+ onInput={(e) => setCssValue(ctrl.key, e.currentTarget.value)}
+ style={{
+ width: "100%",
+ height: "4px",
+ "accent-color": "var(--text-interactive-base)",
+ cursor: "pointer",
+ }}
+ />
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+
+ {/* Export section */}
+ <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+ <button
+ style={{
+ width: "100%",
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "space-between",
+ padding: "10px 12px",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ "font-weight": "500",
+ "font-size": "13px",
+ color: "var(--text-strong)",
+ }}
+ onClick={() => setPanels("export", (v) => !v)}
+ >
+ Export CSS
+ <span>{panels.export ? "−" : "+"}</span>
+ </button>
+ <Show when={panels.export}>
+ <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "8px" }}>
+ <button
+ style={{
+ padding: "6px 12px",
+ "border-radius": "4px",
+ border: "1px solid var(--border-interactive-base)",
+ background: "var(--surface-interactive-weak)",
+ cursor: "pointer",
+ "font-size": "12px",
+ "font-weight": "500",
+ color: "var(--text-interactive-base)",
+ }}
+ onClick={() => setExported(exportCss())}
+ >
+ Copy CSS to clipboard
+ </button>
+ <Show when={exported()}>
+ <pre
+ style={{
+ padding: "8px",
+ "border-radius": "4px",
+ background: "var(--surface-inset-base)",
+ border: "1px solid var(--border-weak-base)",
+ "font-size": "11px",
+ "font-family": "var(--font-family-mono)",
+ "line-height": "1.5",
+ "white-space": "pre-wrap",
+ "word-break": "break-all",
+ "max-height": "300px",
+ "overflow-y": "auto",
+ color: "var(--text-base)",
+ }}
+ >
+ {exported()}
+ </pre>
+ </Show>
+ </div>
+ </Show>
+ </div>
+ </div>
+
+ {/* Main area: timeline preview */}
+ <div style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}>
+ <DataProvider data={data()} directory="/project">
+ <FileComponentProvider component={FileStub}>
+ <div
+ style={{
+ "max-width": "800px",
+ margin: "0 auto",
+ padding: "16px 0",
+ }}
+ >
+ <Show
+ when={userMessages().length > 0}
+ fallback={
+ <div
+ style={{
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "center",
+ height: "400px",
+ color: "var(--text-weak)",
+ "font-size": "14px",
+ }}
+ >
+ Click a generator button to add messages
+ </div>
+ }
+ >
+ <div
+ role="log"
+ style={{ display: "flex", "flex-direction": "column", gap: "48px", width: "100%", padding: "0 20px" }}
+ >
+ <For each={userMessages()}>
+ {(msg) => (
+ <div style={{ width: "100%" }}>
+ <SessionTurn
+ sessionID={SESSION_ID}
+ messageID={msg.id}
+ messages={state.messages}
+ active={false}
+ showReasoningSummaries={true}
+ shellToolDefaultOpen={true}
+ editToolDefaultOpen={true}
+ classes={{
+ root: "min-w-0 w-full relative",
+ content: "flex flex-col justify-between !overflow-visible",
+ container: "w-full",
+ }}
+ />
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+ </FileComponentProvider>
+ </DataProvider>
+ </div>
+ </div>
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Story export
+// ---------------------------------------------------------------------------
+export default {
+ title: "Playground/Timeline",
+ id: "playground-timeline",
+ parameters: {
+ layout: "fullscreen",
+ },
+}
+
+export const Basic = {
+ render: () => <Playground />,
+}