chore(app): markdown playground in storyboard
parent
b480a38d31
commit
0c0c6f3bdb
|
|
@ -896,7 +896,8 @@ export function MessageTimeline(props: {
|
|||
</Show>
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
data-slot="session-turn-list"
|
||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineMain } from "storybook-solidjs-vite"
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { playgroundCss } from "./playground-css-plugin"
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ui = path.resolve(here, "../../ui")
|
||||
|
|
@ -24,7 +25,7 @@ export default defineMain({
|
|||
async viteFinal(config) {
|
||||
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [tailwindcss(), playgroundCss()],
|
||||
resolve: {
|
||||
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
|
||||
alias: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Vite plugin that exposes a POST endpoint for the timeline playground
|
||||
* to write CSS changes back to source files on disk.
|
||||
*
|
||||
* POST /__playground/apply-css
|
||||
* Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
|
||||
*
|
||||
* For each edit the plugin finds `anchor` in the file, then locates the
|
||||
* next `prop: <anything>;` 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: <value>`
|
||||
// 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<string, Edit[]>()
|
||||
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 }))
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -226,3 +226,7 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-list"] {
|
||||
gap: 48px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({})
|
||||
const [defaults, setDefaults] = createStore<Record<string, string>>({})
|
||||
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<string, string> = {}
|
||||
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 ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -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() {
|
|||
</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())}
|
||||
>
|
||||
<button style={btnAccent} onClick={() => setExported(exportCss())}>
|
||||
Copy CSS to clipboard
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...btnAccent,
|
||||
opacity: changedControls().length === 0 || applying() ? "0.5" : "1",
|
||||
cursor: changedControls().length === 0 || applying() ? "not-allowed" : "pointer",
|
||||
}}
|
||||
disabled={changedControls().length === 0 || applying()}
|
||||
onClick={applyToSource}
|
||||
>
|
||||
{applying()
|
||||
? "Applying..."
|
||||
: `Apply ${changedControls().length} edit${changedControls().length === 1 ? "" : "s"} to source`}
|
||||
</button>
|
||||
<Show when={changedControls().length > 0}>
|
||||
<div
|
||||
style={{
|
||||
"font-size": "10px",
|
||||
color: "var(--text-weaker)",
|
||||
"line-height": "1.4",
|
||||
}}
|
||||
>
|
||||
<For each={changedControls()}>
|
||||
{(ctrl) => (
|
||||
<div>
|
||||
{ctrl.source!.file}: {ctrl.property} = {css[ctrl.key]}
|
||||
{ctrl.unit}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={applyResult()}>
|
||||
<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": "200px",
|
||||
"overflow-y": "auto",
|
||||
color: "var(--text-base)",
|
||||
}}
|
||||
>
|
||||
{applyResult()}
|
||||
</pre>
|
||||
</Show>
|
||||
<Show when={exported()}>
|
||||
<pre
|
||||
style={{
|
||||
|
|
@ -1502,7 +1690,10 @@ function Playground() {
|
|||
</div>
|
||||
|
||||
{/* Main area: timeline preview */}
|
||||
<div style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}>
|
||||
<div
|
||||
ref={previewRef!}
|
||||
style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}
|
||||
>
|
||||
<DataProvider data={data()} directory="/project">
|
||||
<FileComponentProvider component={FileStub}>
|
||||
<div
|
||||
|
|
@ -1531,7 +1722,8 @@ function Playground() {
|
|||
>
|
||||
<div
|
||||
role="log"
|
||||
style={{ display: "flex", "flex-direction": "column", gap: "48px", width: "100%", padding: "0 20px" }}
|
||||
data-slot="session-turn-list"
|
||||
style={{ display: "flex", "flex-direction": "column", width: "100%", padding: "0 20px" }}
|
||||
>
|
||||
<For each={userMessages()}>
|
||||
{(msg) => (
|
||||
|
|
|
|||
Loading…
Reference in New Issue