chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
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")
const app = path.resolve(here, "../../app/src")
const mocks = path.resolve(here, "./mocks")
export default defineMain({
framework: {
name: "storybook-solidjs-vite",
options: {},
},
addons: [
"@storybook/addon-onboarding",
"@storybook/addon-docs",
"@storybook/addon-links",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
],
stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
return mergeConfig(config, {
plugins: [tailwindcss(), playgroundCss()],
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
alias: [
{ find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") },
{ find: /^@\/context\/local$/, replacement: path.resolve(mocks, "app/context/local.ts") },
{ find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") },
{ find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") },
{ find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") },
{ find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") },
{ find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") },
{ find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") },
{ find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") },
{ find: /^@\/context\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") },
{ find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
{ find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
{ find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
{ find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
{
find: /^@\/components\/dialog-select-model$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model.tsx"),
},
{
find: /^@\/components\/dialog-select-model-unpaid$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model-unpaid.tsx"),
},
{ find: "@", replacement: app },
],
},
worker: {
format: "es",
},
server: {
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), ui, app, mocks],
},
},
})
},
})

View File

@@ -0,0 +1,11 @@
import { addons, types } from "storybook/manager-api"
import { ThemeTool } from "./theme-tool"
addons.register("opencode/theme-toggle", () => {
addons.add("opencode/theme-toggle/tool", {
type: types.TOOL,
title: "Theme",
match: ({ viewMode }) => viewMode === "story" || viewMode === "docs",
render: ThemeTool,
})
})

View File

@@ -0,0 +1,3 @@
export function DialogSelectModelUnpaid() {
return <div data-component="dialog-select-model-unpaid">Select model</div>
}

View File

@@ -0,0 +1,7 @@
import { splitProps } from "solid-js"
export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record<string, unknown>; children: any }) {
const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"])
const Trigger = local.triggerAs
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
}

View File

@@ -0,0 +1,22 @@
const keybinds: Record<string, string> = {
"file.attach": "mod+u",
"prompt.mode.shell": "mod+shift+x",
"prompt.mode.normal": "mod+shift+e",
"permissions.autoaccept": "mod+shift+a",
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
}
export function useCommand() {
return {
options: [],
register() {
return () => undefined
},
trigger() {},
keybind(id: string) {
return keybinds[id]
},
}
}

View File

@@ -0,0 +1,34 @@
import { createSignal } from "solid-js"
type Comment = {
id: string
file: string
selection: { start: number; end: number }
comment: string
time: number
}
const [list, setList] = createSignal<Comment[]>([])
const [focus, setFocus] = createSignal<{ file: string; id: string } | null>(null)
const [active, setActive] = createSignal<{ file: string; id: string } | null>(null)
export function useComments() {
return {
all: list,
replace(next: Comment[]) {
setList(next)
},
remove(file: string, id: string) {
setList((current) => current.filter((item) => !(item.file === file && item.id === id)))
},
clear() {
setList([])
setFocus(null)
setActive(null)
},
focus,
setFocus,
active,
setActive,
}
}

View File

@@ -0,0 +1,47 @@
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
}
export function selectionFromLines(selection?: SelectedLineRange): FileSelection | undefined {
if (!selection) return undefined
return {
startLine: selection.start,
startChar: 0,
endLine: selection.end,
endChar: 0,
}
}
const pool = [
"src/session/timeline.tsx",
"src/session/composer.tsx",
"src/components/prompt-input.tsx",
"src/components/session-todo-dock.tsx",
"README.md",
]
export function useFile() {
return {
tab(path: string) {
return `file:${path}`
},
pathFromTab(tab: string) {
if (!tab.startsWith("file:")) return ""
return tab.slice(5)
},
load: async () => undefined,
async searchFilesAndDirectories(query: string) {
const text = query.trim().toLowerCase()
if (!text) return pool
return pool.filter((path) => path.toLowerCase().includes(text))
},
}
}

View File

@@ -0,0 +1,42 @@
import { createStore } from "solid-js/store"
const provider = {
all: [
{
id: "anthropic",
models: {
"claude-3-7-sonnet": {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
cost: { input: 1, output: 1 },
},
},
},
],
connected: ["anthropic"],
default: { anthropic: "claude-3-7-sonnet" },
}
const [store, setStore] = createStore({
todo: {} as Record<string, any[]>,
provider,
session: [] as any[],
config: { permission: {} },
})
export function useGlobalSync() {
return {
data: {
provider,
session_todo: store.todo,
},
child() {
return [store, setStore] as const
},
todo: {
set(sessionID: string, todos: any[]) {
setStore("todo", sessionID, todos)
},
},
}
}

View File

@@ -0,0 +1,75 @@
const dict: Record<string, string> = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse todos",
"session.todo.expand": "Expand todos",
"prompt.loading": "Loading prompt...",
"prompt.placeholder.normal": "Ask anything...",
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.shell": "Run a shell command... {{example}}",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
"prompt.action.attachFile": "Attach files",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.attachment.remove": "Remove attachment",
"prompt.dropzone.label": "Drop image to attach",
"prompt.dropzone.file.label": "Drop file to attach",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"dialog.model.select.title": "Select model",
"common.default": "Default",
"common.key.esc": "Esc",
"command.category.file": "File",
"command.category.session": "Session",
"command.agent.cycle": "Cycle agent",
"command.model.choose": "Choose model",
"command.model.variant.cycle": "Cycle model variant",
"command.prompt.mode.shell": "Switch to shell mode",
"command.prompt.mode.normal": "Switch to prompt mode",
"command.permissions.autoaccept.enable": "Enable auto-accept",
"command.permissions.autoaccept.disable": "Disable auto-accept",
"prompt.example.1": "Refactor this function and keep behavior the same",
"prompt.example.2": "Find the root cause of this error",
"prompt.example.3": "Write tests for this module",
"prompt.example.4": "Explain this diff",
"prompt.example.5": "Optimize this query",
"prompt.example.6": "Clean up this component",
"prompt.example.7": "Summarize the recent changes",
"prompt.example.8": "Add accessibility checks",
"prompt.example.9": "Review this API design",
"prompt.example.10": "Generate migration notes",
"prompt.example.11": "Patch this bug",
"prompt.example.12": "Make this animation smoother",
"prompt.example.13": "Improve error handling",
"prompt.example.14": "Document this feature",
"prompt.example.15": "Refine these styles",
"prompt.example.16": "Check edge cases",
"prompt.example.17": "Help me write a commit message",
"prompt.example.18": "Reduce re-renders in this component",
"prompt.example.19": "Verify keyboard navigation",
"prompt.example.20": "Make this copy clearer",
"prompt.example.21": "Add telemetry for this flow",
"prompt.example.22": "Compare these two implementations",
"prompt.example.23": "Create a minimal reproduction",
"prompt.example.24": "Suggest naming improvements",
"prompt.example.25": "What should we test next?",
}
function render(template: string, params?: Record<string, unknown>) {
if (!params) return template
return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
const value = params[key.trim()]
if (value === undefined || value === null) return ""
// oxlint-disable-next-line no-base-to-string -- value is Record<string, unknown>, always coerced intentionally
return String(value)
})
}
export function useLanguage() {
return {
locale: () => "en" as const,
t(key: string, params?: Record<string, unknown>) {
return render(dict[key] ?? key, params)
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const [all, setAll] = createSignal<string[]>([])
const [active, setActive] = createSignal<string | undefined>(undefined)
const [reviewOpen, setReviewOpen] = createSignal(false)
const tabs = {
all,
active,
open(tab: string) {
setAll((current) => (current.includes(tab) ? current : [...current, tab]))
},
setActive(tab: string) {
if (!all().includes(tab)) {
tabs.open(tab)
}
setActive(tab)
},
}
const view = {
reviewPanel: {
opened: reviewOpen,
open() {
setReviewOpen(true)
},
},
}
export function useLayout() {
return {
tabs: () => tabs,
view: () => view,
fileTree: {
setTab() {},
},
handoff: {
setTabs() {},
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const model = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic" },
variants: { fast: {}, thinking: {} },
}
const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }]
const [agent, setAgent] = createSignal(agents[0].name)
const [variant, setVariant] = createSignal<string | undefined>(undefined)
export function useLocal() {
return {
slug: () => "c3Rvcnk=",
agent: {
list: () => agents,
current: () => agents.find((item) => item.name === agent()) ?? agents[0],
set(value?: string) {
if (!value) {
setAgent(agents[0].name)
return
}
const hit = agents.find((item) => item.name === value)
setAgent(hit?.name ?? agents[0].name)
},
},
model: {
current: () => model,
variant: {
list: () => Object.keys(model.variants),
current: () => variant(),
set(next?: string) {
setVariant(next)
},
},
},
}
}

View File

@@ -0,0 +1,24 @@
const accepted = new Set<string>()
function key(sessionID: string, directory?: string) {
return `${directory ?? ""}:${sessionID}`
}
export function usePermission() {
return {
autoResponds() {
return false
},
isAutoAccepting(sessionID: string, directory?: string) {
return accepted.has(key(sessionID, directory))
},
toggleAutoAccept(sessionID: string, directory?: string) {
const next = key(sessionID, directory)
if (accepted.has(next)) {
accepted.delete(next)
return
}
accepted.add(next)
},
}
}

View File

@@ -0,0 +1,16 @@
import type { Platform } from "../../../../../app/src/context/platform"
const value: Platform = {
platform: "web",
openLink() {},
restart: async () => {},
back() {},
forward() {},
notify: async () => {},
fetch: globalThis.fetch.bind(globalThis),
parseMarkdown: async (markdown: string) => markdown,
}
export function usePlatform() {
return value
}

View File

@@ -0,0 +1,117 @@
import { createSignal } from "solid-js"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
type ContextItem = {
key: string
type: "file"
path: string
selection?: { startLine: number; startChar: number; endLine: number; endChar: number }
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function clonePart(part: ContentPart): ContentPart {
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "file") return { ...part }
return { ...part }
}
function clonePrompt(prompt: Prompt) {
return prompt.map(clonePart)
}
export function isPromptEqual(a: Prompt, b: Prompt) {
if (a.length !== b.length) return false
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
}
let index = 0
const [prompt, setPrompt] = createSignal<Prompt>(clonePrompt(DEFAULT_PROMPT))
const [cursor, setCursor] = createSignal<number>(0)
const [items, setItems] = createSignal<ContextItem[]>([])
const withKey = (item: Omit<ContextItem, "key"> & { key?: string }): ContextItem => ({
...item,
key: item.key ?? `ctx:${++index}`,
})
export function usePrompt() {
return {
ready: () => true,
current: prompt,
cursor,
dirty: () => !isPromptEqual(prompt(), DEFAULT_PROMPT),
set(next: Prompt, cursorPosition?: number) {
setPrompt(clonePrompt(next))
if (cursorPosition !== undefined) setCursor(cursorPosition)
},
reset() {
setPrompt(clonePrompt(DEFAULT_PROMPT))
setCursor(0)
setItems((current) => current.filter((item) => !!item.comment?.trim()))
},
context: {
items,
add(item: Omit<ContextItem, "key"> & { key?: string }) {
const next = withKey(item)
if (items().some((current) => current.key === next.key)) return
setItems((current) => [...current, next])
},
remove(key: string) {
setItems((current) => current.filter((item) => item.key !== key))
},
removeComment(path: string, commentID: string) {
setItems((current) =>
current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<ContextItem>) {
setItems((current) =>
current.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
return withKey({ ...item, ...next })
}),
)
},
replaceComments(next: Array<Omit<ContextItem, "key"> & { key?: string }>) {
const nonComment = items().filter((item) => !item.comment?.trim())
setItems([...nonComment, ...next.map(withKey)])
},
},
}
}

View File

@@ -0,0 +1,25 @@
const make = (directory: string) => ({
session: {
create: async () => ({ data: { id: "story-session" } }),
prompt: async () => ({ data: undefined }),
shell: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/worktree-1` } }),
},
})
const root = "/tmp/story"
export function useSDK() {
return {
directory: root,
url: "http://localhost:4096",
client: make(root),
createClient(input: { directory: string }) {
return make(input.directory)
},
}
}

View File

@@ -0,0 +1,32 @@
import { createStore } from "solid-js/store"
const [data, setData] = createStore({
session: [] as Array<{ id: string; parentID?: string }>,
permission: {} as Record<string, Array<{ id: string; sessionID: string; permission: string; patterns: string[] }>>,
question: {} as Record<string, Array<{ id: string; questions: unknown[] }>>,
session_diff: {} as Record<string, Array<{ file: string }>>,
message: {
"story-session": [] as Array<{ id: string; role: string }>,
} as Record<string, Array<{ id: string; role: string }>>,
session_status: {} as Record<string, { type: "idle" | "busy" }>,
agent: [{ name: "build", mode: "task", hidden: false }],
command: [{ name: "fix", description: "Run fix command", source: "project" }],
})
export function useSync() {
return {
data,
set(...input: unknown[]) {
;(setData as (...args: unknown[]) => void)(...input)
},
session: {
get(id: string) {
return { id }
},
optimistic: {
add() {},
remove() {},
},
},
}
}

View File

@@ -0,0 +1,23 @@
const model_id = "claude-3-7-sonnet"
const provider = {
id: "anthropic",
models: {
[model_id]: {
id: model_id,
name: "Claude 3.7 Sonnet",
cost: { input: 1, output: 1 },
variants: { fast: {}, thinking: {} },
},
},
}
export function useProviders() {
return {
all: () => [provider],
default: () => ({ anthropic: model_id }),
connected: () => [provider],
paid: () => [provider],
popular: () => [provider],
}
}

View File

@@ -0,0 +1,28 @@
import type { ParentProps } from "solid-js"
export function useParams() {
return {
dir: "c3Rvcnk=",
id: "story-session",
}
}
export function useNavigate() {
return () => undefined
}
export function useLocation() {
return {
pathname: "/story/session/story-session",
search: "",
hash: "",
}
}
export function MemoryRouter(props: ParentProps) {
return props.children
}
export function Route(props: ParentProps) {
return props.children
}

View File

@@ -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 }))
})
})
},
}
}

View File

@@ -0,0 +1,98 @@
import "@opencode-ai/ui/styles/tailwind"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
import addonDocs from "@storybook/addon-docs"
import { MetaProvider } from "@solidjs/meta"
import { addons } from "storybook/preview-api"
import { GLOBALS_UPDATED } from "storybook/internal/core-events"
import { createJSXDecorator, definePreview } from "storybook-solidjs-vite"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { Font } from "@opencode-ai/ui/font"
function resolveScheme(value: unknown): ColorScheme {
if (value === "light" || value === "dark" || value === "system") return value
return "system"
}
const channel = addons.getChannel()
const Scheme = (props: { value?: unknown }) => {
const theme = useTheme()
const apply = (value?: unknown) => {
theme.setColorScheme(resolveScheme(value))
}
createEffect(() => {
apply(props.value)
})
createEffect(() => {
const root = document.documentElement
root.classList.remove("light", "dark")
root.classList.add(theme.mode())
})
onMount(() => {
const handler = (event: { globals?: Record<string, unknown> }) => {
apply(event.globals?.theme)
}
channel.on(GLOBALS_UPDATED, handler)
onCleanup(() => channel.off(GLOBALS_UPDATED, handler))
})
return null
}
const frame = createJSXDecorator((Story, context) => {
const override = context.parameters?.themes?.themeOverride
const selected = context.globals?.theme
const pick = override === "light" || override === "dark" ? override : selected
const scheme = resolveScheme(pick)
return (
<MetaProvider>
<Font />
<ThemeProvider>
<Scheme value={scheme} />
<DialogProvider>
<MarkedProvider>
<div
style={{
"min-height": "100vh",
padding: "24px",
"background-color": "var(--background-base)",
color: "var(--text-base)",
}}
>
<Story />
</div>
</MarkedProvider>
</DialogProvider>
</ThemeProvider>
</MetaProvider>
)
})
export default definePreview({
addons: [addonDocs(), addonA11y()],
decorators: [frame],
globalTypes: {
theme: {
name: "Theme",
description: "Global theme",
defaultValue: "light",
},
},
parameters: {
actions: {
argTypesRegex: "^on.*",
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
test: "todo",
},
},
})

View File

@@ -0,0 +1,21 @@
import { createElement } from "react"
import { useGlobals } from "storybook/manager-api"
import { ToggleButton } from "storybook/internal/components"
export function ThemeTool() {
const [globals, updateGlobals] = useGlobals()
const mode = globals.theme === "dark" ? "dark" : "light"
const toggle = () => {
const next = mode === "dark" ? "light" : "dark"
updateGlobals({ theme: next })
}
return createElement(
ToggleButton,
{
title: "Toggle theme",
active: mode === "dark",
onClick: toggle,
},
mode === "dark" ? "Dark" : "Light",
)
}