From 0c0c6f3bdb75663837555b07110d7a1b313daeac Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 25 Mar 2026 09:14:20 -0500
Subject: [PATCH] chore(app): markdown playground in storyboard
---
.../src/pages/session/message-timeline.tsx | 3 +-
packages/storybook/.storybook/main.ts | 3 +-
.../.storybook/playground-css-plugin.ts | 136 ++++++++++
packages/ui/src/components/session-turn.css | 4 +
.../timeline-playground.stories.tsx | 232 ++++++++++++++++--
5 files changed, 356 insertions(+), 22 deletions(-)
create mode 100644 packages/storybook/.storybook/playground-css-plugin.ts
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 5fef41a550..33437ce9c9 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
}
+ *
+ * For each edit the plugin finds `anchor` in the file, then locates the
+ * next `prop:
;` after it and replaces the value portion.
+ * `file` is a basename resolved relative to packages/ui/src/components/.
+ */
+import type { Plugin } from "vite"
+import type { IncomingMessage, ServerResponse } from "node:http"
+import fs from "node:fs"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const root = path.resolve(here, "../../ui/src/components")
+
+const ENDPOINT = "/__playground/apply-css"
+
+type Edit = { file: string; anchor: string; prop: string; value: string }
+type Result = { file: string; prop: string; ok: boolean; error?: string }
+
+function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } {
+ const results: Result[] = []
+ let out = content
+
+ for (const edit of edits) {
+ const name = edit.file
+ const idx = out.indexOf(edit.anchor)
+ if (idx === -1) {
+ results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` })
+ continue
+ }
+
+ // From the anchor position, find the next occurrence of `prop: `
+ // We match `prop:` followed by any value up to `;`
+ const after = out.slice(idx)
+ const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`)
+ const match = re.exec(after)
+ if (!match) {
+ results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` })
+ continue
+ }
+
+ const start = idx + match.index + match[1].length
+ const end = start + match[2].length
+ out = out.slice(0, start) + edit.value + out.slice(end)
+ results.push({ file: name, prop: edit.prop, ok: true })
+ }
+
+ return { content: out, results }
+}
+
+function escapeRegex(s: string) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+export function playgroundCss(): Plugin {
+ return {
+ name: "playground-css",
+ configureServer(server) {
+ server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
+ if (req.url !== ENDPOINT) return next()
+ if (req.method !== "POST") {
+ res.statusCode = 405
+ res.setHeader("Content-Type", "application/json")
+ res.end(JSON.stringify({ error: "Method not allowed" }))
+ return
+ }
+
+ let data = ""
+ req.on("data", (chunk: Buffer) => {
+ data += chunk.toString()
+ })
+ req.on("end", () => {
+ let payload: { edits: Edit[] }
+ try {
+ payload = JSON.parse(data)
+ } catch {
+ res.statusCode = 400
+ res.setHeader("Content-Type", "application/json")
+ res.end(JSON.stringify({ error: "Invalid JSON" }))
+ return
+ }
+
+ if (!Array.isArray(payload.edits)) {
+ res.statusCode = 400
+ res.setHeader("Content-Type", "application/json")
+ res.end(JSON.stringify({ error: "Missing edits array" }))
+ return
+ }
+
+ // Group by file
+ const grouped = new Map()
+ for (const edit of payload.edits) {
+ if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue
+ const abs = path.resolve(root, edit.file)
+ if (!abs.startsWith(root)) continue
+ const key = abs
+ if (!grouped.has(key)) grouped.set(key, [])
+ grouped.get(key)!.push(edit)
+ }
+
+ const results: Result[] = []
+
+ for (const [abs, edits] of grouped) {
+ const name = path.basename(abs)
+ if (!fs.existsSync(abs)) {
+ for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" })
+ continue
+ }
+
+ try {
+ const content = fs.readFileSync(abs, "utf-8")
+ const applied = applyEdits(content, edits)
+ results.push(...applied.results)
+
+ if (applied.results.some((r) => r.ok)) {
+ fs.writeFileSync(abs, applied.content, "utf-8")
+ }
+ } catch (err) {
+ for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) })
+ }
+ }
+
+ res.statusCode = 200
+ res.setHeader("Content-Type", "application/json")
+ res.end(JSON.stringify({ results }))
+ })
+ })
+ },
+ }
+}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 8075d25777..c4a8726ac5 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -226,3 +226,7 @@
display: none;
}
}
+
+[data-slot="session-turn-list"] {
+ gap: 48px;
+}
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx
index 16bf2591a5..aa20ba940a 100644
--- a/packages/ui/src/components/timeline-playground.stories.tsx
+++ b/packages/ui/src/components/timeline-playground.stories.tsx
@@ -1,5 +1,5 @@
// @ts-nocheck
-import { createSignal, createMemo, For, Show, Index, batch } from "solid-js"
+import { createSignal, createMemo, createEffect, on, For, Show, Index, batch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type {
Message,
@@ -515,6 +515,26 @@ function compactionPart(): CompactionPart {
// ---------------------------------------------------------------------------
// CSS Controls definition
// ---------------------------------------------------------------------------
+
+// Source file basenames inside packages/ui/src/components/
+const MD = "markdown.css"
+const MP = "message-part.css"
+const ST = "session-turn.css"
+
+/**
+ * Source mapping for a CSS control.
+ * - `anchor`: immutable text near the property (comment, selector, etc.) that
+ * won't change when values change — used to locate the right rule block.
+ * - `prop`: the CSS property name whose value gets replaced.
+ * - `format`: turns the slider number into a CSS value string.
+ */
+type CSSSource = {
+ file: string
+ anchor: string
+ prop: string
+ format: (v: string) => string
+}
+
type CSSControl = {
key: string
label: string
@@ -528,8 +548,13 @@ type CSSControl = {
step?: string
options?: string[]
unit?: string
+ source?: CSSSource
}
+const px = (v: string) => `${v}px`
+const pxZero = (v: string) => `${v}px 0`
+const pct = (v: string) => `${v}%`
+
const CSS_CONTROLS: CSSControl[] = [
// --- Timeline spacing ---
{
@@ -537,13 +562,14 @@ const CSS_CONTROLS: CSSControl[] = [
label: "Turn gap",
group: "Timeline Spacing",
type: "range",
- initial: "12",
- selector: '[role="log"]',
+ initial: "48",
+ selector: '[data-slot="session-turn-list"]',
property: "gap",
min: "0",
max: "80",
step: "1",
unit: "px",
+ source: { file: ST, anchor: '[data-slot="session-turn-list"]', prop: "gap", format: px },
},
{
key: "container-gap",
@@ -557,6 +583,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: ST, anchor: '[data-slot="session-turn-message-container"]', prop: "gap", format: px },
},
{
key: "assistant-gap",
@@ -570,6 +597,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: ST, anchor: '[data-slot="session-turn-assistant-content"]', prop: "gap", format: px },
},
{
key: "text-part-margin",
@@ -583,6 +611,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MP, anchor: '[data-component="text-part"]', prop: "margin-top", format: px },
},
// --- Markdown typography ---
@@ -598,6 +627,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "22",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "font-size", format: px },
},
{
key: "md-line-height",
@@ -611,6 +641,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "300",
step: "5",
unit: "%",
+ source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "line-height", format: pct },
},
// --- Markdown headings ---
@@ -626,6 +657,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Headings:", prop: "margin-top", format: px },
},
{
key: "md-heading-margin-bottom",
@@ -639,6 +671,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Headings:", prop: "margin-bottom", format: px },
},
{
key: "md-heading-font-size",
@@ -652,6 +685,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "28",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Headings:", prop: "font-size", format: px },
},
// --- Markdown paragraphs ---
@@ -667,6 +701,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Paragraphs */", prop: "margin-bottom", format: px },
},
// --- Markdown lists ---
@@ -682,6 +717,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Lists */", prop: "margin-top", format: px },
},
{
key: "md-list-margin-bottom",
@@ -695,6 +731,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Lists */", prop: "margin-bottom", format: px },
},
{
key: "md-list-padding-left",
@@ -708,6 +745,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Lists */", prop: "padding-left", format: px },
},
{
key: "md-li-margin-bottom",
@@ -721,6 +759,8 @@ const CSS_CONTROLS: CSSControl[] = [
max: "20",
step: "1",
unit: "px",
+ // Anchor on `li {` to skip the `ul,ol` margin-bottom above
+ source: { file: MD, anchor: "\n li {", prop: "margin-bottom", format: px },
},
// --- Markdown code blocks ---
@@ -736,6 +776,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "\n pre {", prop: "margin-top", format: px },
},
{
key: "md-pre-margin-bottom",
@@ -749,6 +790,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "\n pre {", prop: "margin-bottom", format: px },
},
{
key: "md-shiki-font-size",
@@ -762,6 +804,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "20",
step: "1",
unit: "px",
+ source: { file: MD, anchor: ".shiki {", prop: "font-size", format: px },
},
{
key: "md-shiki-padding",
@@ -775,6 +818,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "32",
step: "1",
unit: "px",
+ source: { file: MD, anchor: ".shiki {", prop: "padding", format: px },
},
{
key: "md-shiki-radius",
@@ -788,6 +832,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "16",
step: "1",
unit: "px",
+ source: { file: MD, anchor: ".shiki {", prop: "border-radius", format: px },
},
// --- Markdown blockquotes ---
@@ -803,6 +848,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Blockquotes */", prop: "margin", format: pxZero },
},
{
key: "md-blockquote-padding-left",
@@ -816,6 +862,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "40",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Blockquotes */", prop: "padding-left", format: px },
},
{
key: "md-blockquote-border-width",
@@ -829,6 +876,12 @@ const CSS_CONTROLS: CSSControl[] = [
max: "8",
step: "1",
unit: "px",
+ source: {
+ file: MD,
+ anchor: "/* Blockquotes */",
+ prop: "border-left",
+ format: (v) => `${v}px solid var(--border-weak-base)`,
+ },
},
// --- Markdown tables ---
@@ -844,6 +897,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Tables */", prop: "margin", format: pxZero },
},
{
key: "md-td-padding",
@@ -857,6 +911,8 @@ const CSS_CONTROLS: CSSControl[] = [
max: "24",
step: "1",
unit: "px",
+ // Anchor on td selector to skip other padding rules
+ source: { file: MD, anchor: "th,\n td {", prop: "padding", format: px },
},
// --- Markdown HR ---
@@ -872,6 +928,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "80",
step: "1",
unit: "px",
+ source: { file: MD, anchor: "/* Horizontal Rule", prop: "margin", format: pxZero },
},
// --- Reasoning part ---
@@ -887,6 +944,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "60",
step: "1",
unit: "px",
+ source: { file: MP, anchor: '[data-component="reasoning-part"]', prop: "margin-top", format: px },
},
// --- User message ---
@@ -902,6 +960,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "32",
step: "1",
unit: "px",
+ source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "padding", format: px },
},
{
key: "user-msg-radius",
@@ -915,6 +974,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "24",
step: "1",
unit: "px",
+ source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "border-radius", format: px },
},
// --- Tool parts ---
@@ -930,6 +990,7 @@ const CSS_CONTROLS: CSSControl[] = [
max: "600",
step: "10",
unit: "px",
+ source: { file: MP, anchor: '[data-slot="bash-scroll"]', prop: "max-height", format: px },
},
]
@@ -952,7 +1013,37 @@ function Playground() {
// ---- CSS overrides ----
const [css, setCss] = createStore>({})
+ const [defaults, setDefaults] = createStore>({})
let styleEl: HTMLStyleElement | undefined
+ let previewRef: HTMLDivElement | undefined
+
+ /** Read computed styles from the DOM to seed slider defaults */
+ const readDefaults = () => {
+ const root = previewRef
+ if (!root) return
+ const next: Record = {}
+ for (const ctrl of CSS_CONTROLS) {
+ const el = root.querySelector(ctrl.selector) as HTMLElement | null
+ if (!el) continue
+ const styles = getComputedStyle(el)
+ // Use bracket access — getPropertyValue doesn't resolve shorthands
+ const raw = (styles as any)[ctrl.property] as string
+ if (!raw) continue
+ // Shorthands may return "24px 0px" — take the first value
+ const num = parseFloat(raw.split(" ")[0])
+ if (!Number.isFinite(num)) continue
+ // line-height returns px — convert back to % relative to font-size
+ if (ctrl.unit === "%") {
+ const fs = parseFloat(styles.fontSize)
+ if (fs > 0) {
+ next[ctrl.key] = String(Math.round((num / fs) * 100))
+ continue
+ }
+ }
+ next[ctrl.key] = String(Math.round(num))
+ }
+ setDefaults(next)
+ }
const updateStyle = () => {
const rules: string[] = []
@@ -993,6 +1084,18 @@ function Playground() {
},
}))
+ // Read computed defaults once DOM has turn elements to query
+ createEffect(
+ on(
+ () => userMessages().length,
+ (len) => {
+ if (len === 0) return
+ // Wait a frame for the DOM to settle after render
+ requestAnimationFrame(readDefaults)
+ },
+ ),
+ )
+
// ---- Find or create the last assistant message to append parts to ----
const lastAssistantID = createMemo(() => {
for (let i = state.messages.length - 1; i >= 0; i--) {
@@ -1156,6 +1259,52 @@ function Playground() {
const [exported, setExported] = createSignal("")
+ // ---- Apply to source files ----
+ const [applying, setApplying] = createSignal(false)
+ const [applyResult, setApplyResult] = createSignal("")
+
+ const changedControls = createMemo(() => CSS_CONTROLS.filter((ctrl) => css[ctrl.key] !== undefined && ctrl.source))
+
+ const applyToSource = async () => {
+ const controls = changedControls()
+ if (controls.length === 0) return
+
+ setApplying(true)
+ setApplyResult("")
+
+ const edits = controls.map((ctrl) => {
+ const src = ctrl.source!
+ return { file: src.file, anchor: src.anchor, prop: src.prop, value: src.format(css[ctrl.key]!) }
+ })
+
+ try {
+ const resp = await fetch("/__playground/apply-css", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ edits }),
+ })
+ const data = await resp.json()
+ const ok = data.results?.filter((r: any) => r.ok).length ?? 0
+ const fail = data.results?.filter((r: any) => !r.ok) ?? []
+ const lines = [`Applied ${ok}/${edits.length} edits`]
+ for (const f of fail) {
+ lines.push(` FAIL ${f.file} ${f.prop}: ${f.error}`)
+ }
+ setApplyResult(lines.join("\n"))
+
+ if (ok > 0) {
+ // Clear overrides — values are now in source CSS, Vite will HMR.
+ resetCss()
+ // Wait for Vite HMR then re-read computed defaults
+ setTimeout(readDefaults, 500)
+ }
+ } catch (err) {
+ setApplyResult(`Error: ${err}`)
+ } finally {
+ setApplying(false)
+ }
+ }
+
// ---- Panel collapse state ----
const [panels, setPanels] = createStore({
generators: true,
@@ -1408,7 +1557,7 @@ function Playground() {
"text-align": "right",
}}
>
- {css[ctrl.key] ?? ctrl.initial}
+ {css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial}
{ctrl.unit ?? ""}
@@ -1417,7 +1566,7 @@ function Playground() {
min={ctrl.min ?? "0"}
max={ctrl.max ?? "100"}
step={ctrl.step ?? "1"}
- value={css[ctrl.key] ?? ctrl.initial}
+ value={css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial}
onInput={(e) => setCssValue(ctrl.key, e.currentTarget.value)}
style={{
width: "100%",
@@ -1461,21 +1610,60 @@ function Playground() {
-