diff --git a/.changeset/playground-design-system-blocks.md b/.changeset/playground-design-system-blocks.md new file mode 100644 index 00000000..05ea8f61 --- /dev/null +++ b/.changeset/playground-design-system-blocks.md @@ -0,0 +1,5 @@ +--- +"@styleframe/theme": patch +--- + +Simplify field-group recipe selector: consolidate `.input`, `.select`, `.textarea` flex-grow rules into a single joined selector. diff --git a/apps/playground/AGENTS.md b/apps/playground/AGENTS.md index baf23529..507bd111 100644 --- a/apps/playground/AGENTS.md +++ b/apps/playground/AGENTS.md @@ -1,11 +1,12 @@ # Styleframe Playground -Interactive client-side playground for Styleframe. Users edit three files in CodeMirror; an iframe renders the live result. +Interactive client-side playground for Styleframe. Users edit an arbitrary set of files (React `tsx` components + a `styleframe.config.ts`) in CodeMirror; an iframe renders the live result. ## Package - **Name:** `@styleframe/playground` -- **Build tool:** Vite 7 + Vue 3 +- **Shell:** Vite + Vue 3 (the playground UI is a Vue app; it is invisible to users) +- **Preview:** React 19 + `tsx`, bundled in the browser with `esbuild-wasm` - **Runs:** fully client-side — no server, no auth, no persistence. - **Not published to npm.** @@ -14,42 +15,35 @@ Interactive client-side playground for Styleframe. Users edit three files in Cod ``` apps/playground/ ├── styleframe.config.ts # shell: theme presets + #app / html selectors -├── vite.config.ts # Styleframe Vite plugin + vue() +├── vite.config.ts # Styleframe Vite plugin (minify:false) + vue() + React vendoring ├── index.html ├── src/ -│ ├── App.vue # playground shell root +│ ├── App.vue # playground shell root (Editor │ Output │ Tree) │ ├── main.ts # imports virtual:styleframe.css, mounts App -│ ├── recipes/ # playground-local recipes (pg-*) -│ │ ├── useTabRecipe.ts -│ │ ├── useTabListRecipe.ts -│ │ ├── useSplitPaneRecipe.ts -│ │ ├── useEditorShellRecipe.ts -│ │ ├── useErrorBannerRecipe.ts -│ │ ├── useToolbarRecipe.ts -│ │ ├── index.ts # barrel +│ ├── recipes/ # playground-local recipes (pg-*), incl. useFileTreeRecipe │ │ └── playground.styleframe.ts # registers recipes into the shell instance │ ├── components/ -│ │ ├── SplitPane.vue -│ │ ├── TabList.vue -│ │ ├── EditorPane.vue # 3-tab editor (config, App, Component) -│ │ ├── OutputPane.vue # 3-tab output (Preview, CSS, JS) -│ │ ├── CodeOutput.vue # read-only CodeMirror +│ │ ├── SplitPane.vue # Editor │ Output split +│ │ ├── FileTree.vue # project tree (far right): create / rename / delete +│ │ ├── EditorPane.vue # dynamic, path-keyed CodeMirror editors + tabs +│ │ ├── OutputPane.vue # Preview / CSS / JS tabs │ │ ├── PreviewFrame.vue # iframe + postMessage listener │ │ └── ErrorBanner.vue │ ├── editor/ -│ │ ├── codemirror.ts # createEditor({parent, doc, language, onChange}) -│ │ └── theme.ts # CodeMirror theme extension +│ │ ├── codemirror.ts # createEditor(...) + languageForPath() (ts / tsx / css) +│ │ └── theme.ts │ ├── pipeline/ │ │ ├── esbuild.ts # lazy esbuild-wasm init (single instance) -│ │ ├── transformTs.ts -│ │ ├── evalUserConfig.ts # rewrites imports, runs via new Function +│ │ ├── transformTs.ts # strips TS from the config before eval +│ │ ├── evalUserConfig.ts # rewrites imports, runs config via new Function +│ │ ├── scanAndRegisterUtilities.ts │ │ ├── transpileStyleframe.ts -│ │ ├── compileVueSfc.ts -│ │ ├── buildSrcdoc.ts +│ │ ├── bundlePreview.ts # esbuild-wasm bundle of all user files → one ESM module +│ │ ├── buildSrcdoc.ts # CSS + React vendor + bundle → srcdoc │ │ └── pipeline.ts # orchestrator + debounce + PipelineResult -│ ├── samples/ # default file contents, imported as ?raw +│ ├── samples/ # default file contents (.tsx + config), imported as ?raw │ └── state/ -│ └── playground.ts # reactive { files, output, error, active* } +│ └── playground.ts # reactive { files, output, error, active* } + file actions └── test/ # Vitest specs for pipeline units ``` @@ -60,36 +54,45 @@ apps/playground/ | Context | Compiled by | Where CSS lives | Runtime | |---|---|---|---| | Shell UI | `@styleframe/plugin/vite` at dev/build time | ` -
- +
+ + `; const revoke = () => { - URL.revokeObjectURL(configUrl); - URL.revokeObjectURL(cardUrl); - URL.revokeObjectURL(buttonUrl); - URL.revokeObjectURL(appUrl); - URL.revokeObjectURL(bootUrl); + URL.revokeObjectURL(reactUrl); + URL.revokeObjectURL(bundleUrl); }; return { srcdoc, revoke }; diff --git a/apps/playground/src/pipeline/bundlePreview.ts b/apps/playground/src/pipeline/bundlePreview.ts new file mode 100644 index 00000000..ffccbf67 --- /dev/null +++ b/apps/playground/src/pipeline/bundlePreview.ts @@ -0,0 +1,221 @@ +import type { Loader, Plugin } from "esbuild-wasm"; +import { getEsbuild } from "./esbuild"; + +export interface BundlePreviewInput { + /** All editable files, keyed by path. */ + files: Record; + /** Entry file — its default export is rendered as the preview root. */ + entryPath: string; + /** The file exporting the Styleframe instance (excluded from bundling). */ + configPath: string; + /** Generated `virtual:styleframe` runtime module (recipe functions). */ + runtimeTs: string; +} + +export interface BundlePreviewResult { + /** The compiled preview as a single ESM module. */ + bundleJs: string; + /** CSS emitted by any user `import './styles.css'` (usually empty). */ + css: string; + /** IIFE that publishes React on `globalThis.PGReactVendor`. */ + reactIife: string; +} + +const SOURCE_EXTENSIONS = ["", ".tsx", ".ts", ".jsx", ".js", ".css"] as const; +const INDEX_EXTENSIONS = [ + "/index.tsx", + "/index.ts", + "/index.jsx", + "/index.js", +] as const; + +/** Resolve a `./` or `../` import against the in-memory file map. */ +function joinPath(baseDir: string, spec: string): string { + const fromRoot = spec.startsWith("/"); + const stack = fromRoot || !baseDir ? [] : baseDir.split("/"); + for (const part of spec.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") stack.pop(); + else stack.push(part); + } + return stack.join("/"); +} + +function loaderFor(path: string): Loader { + if (path.endsWith(".tsx")) return "tsx"; + if (path.endsWith(".ts")) return "ts"; + if (path.endsWith(".jsx")) return "jsx"; + if (path.endsWith(".css")) return "css"; + return "js"; +} + +const VENDOR_SPECIFIERS = new Set([ + "react", + "react-dom/client", + "react/jsx-runtime", + "@styleframe/runtime", + "virtual:styleframe", + "virtual:styleframe.css", +]); + +const VENDOR_GLOBALS: Record = { + react: "React", + "react-dom/client": "ReactDOMClient", + "react/jsx-runtime": "JsxRuntime", +}; + +/** + * A shim module that re-exports a vendored React entry off the global the + * vendor IIFE publishes, so the single bundled React instance is shared. + */ +function reactShim(globalKey: string, keys: string[]): string { + const ref = `globalThis.PGReactVendor.${globalKey}`; + return [ + `const mod = ${ref};`, + "export default mod;", + ...keys.map((key) => `export const ${key} = mod[${JSON.stringify(key)}];`), + ].join("\n"); +} + +function createVirtualFsPlugin( + input: BundlePreviewInput, + vendor: { + reactKeys: string[]; + reactDomClientKeys: string[]; + jsxRuntimeKeys: string[]; + }, + runtimeSrc: string, +): Plugin { + const { files, configPath, runtimeTs } = input; + + function resolveFileKey(path: string): string | null { + for (const ext of SOURCE_EXTENSIONS) { + if (`${path}${ext}` in files) return `${path}${ext}`; + } + for (const ext of INDEX_EXTENSIONS) { + if (`${path}${ext}` in files) return `${path}${ext}`; + } + return null; + } + + return { + name: "pg-virtual-fs", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + const spec = args.path; + if (VENDOR_SPECIFIERS.has(spec)) { + return { path: spec, namespace: "pg-vendor" }; + } + if (spec.startsWith(".") || spec.startsWith("/")) { + const importer = + args.importer && args.importer !== "" ? args.importer : ""; + const baseDir = importer.includes("/") + ? importer.slice(0, importer.lastIndexOf("/")) + : ""; + const key = resolveFileKey(joinPath(baseDir, spec)); + // The config and *.styleframe.ts files are authoring-only — they + // are evaluated in the parent, never bundled into the preview. + if (key && key !== configPath && !key.endsWith(".styleframe.ts")) { + return { path: key, namespace: "pg-file" }; + } + return { + errors: [ + { text: `Cannot resolve "${spec}" from "${args.importer}".` }, + ], + }; + } + return { + errors: [ + { + text: `Unknown import "${spec}". Create it as a file, or import recipes from "virtual:styleframe".`, + }, + ], + }; + }); + + build.onLoad({ filter: /.*/, namespace: "pg-file" }, (args) => ({ + contents: files[args.path] ?? "", + loader: loaderFor(args.path), + resolveDir: "/", + })); + + build.onLoad({ filter: /.*/, namespace: "pg-vendor" }, (args) => { + const spec = args.path; + if (spec === "virtual:styleframe") { + return { contents: runtimeTs, loader: "ts", resolveDir: "/" }; + } + if (spec === "virtual:styleframe.css") { + return { contents: "", loader: "js" }; + } + if (spec === "@styleframe/runtime") { + return { contents: runtimeSrc, loader: "js", resolveDir: "/" }; + } + const globalKey = VENDOR_GLOBALS[spec]!; + const keys = + spec === "react" + ? vendor.reactKeys + : spec === "react-dom/client" + ? vendor.reactDomClientKeys + : vendor.jsxRuntimeKeys; + return { contents: reactShim(globalKey, keys), loader: "js" }; + }); + }, + }; +} + +export async function bundlePreview( + input: BundlePreviewInput, +): Promise { + const esbuild = await getEsbuild(); + const [{ default: reactVendor }, { default: runtimeSrc }] = await Promise.all( + [import("virtual:pg-react-vendor"), import("virtual:pg-runtime-src")], + ); + + const boot = ` +import { createRoot } from "react-dom/client"; +import App from ${JSON.stringify(`./${input.entryPath}`)}; +const notify = (detail) => parent.postMessage({ type: "pg:error", detail }, "*"); +window.addEventListener("error", (event) => { + notify({ message: event.message, stack: event.error && event.error.stack }); +}); +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + notify({ message: reason && reason.message ? reason.message : String(reason), stack: reason && reason.stack }); +}); +try { + createRoot(document.getElementById("root")).render(); +} catch (error) { + notify({ message: error && error.message ? error.message : String(error), stack: error && error.stack }); +} +`; + + const result = await esbuild.build({ + stdin: { + contents: boot, + resolveDir: "/", + sourcefile: "pgBoot.tsx", + loader: "tsx", + }, + bundle: true, + write: false, + // An out dir is required for esbuild to emit CSS from user + // `import './styles.css'`; without it such imports fail to build. + outdir: "/", + format: "esm", + jsx: "automatic", + jsxDev: false, + target: "es2022", + plugins: [createVirtualFsPlugin(input, reactVendor, runtimeSrc)], + }); + + // The JS bundle and any CSS from user imports are emitted as sibling + // outputs; the CSS file is the one whose path ends in `.css`. + const bundleJs = + result.outputFiles.find((file) => !file.path.endsWith(".css"))?.text ?? ""; + const css = result.outputFiles + .filter((file) => file.path.endsWith(".css")) + .map((file) => file.text) + .join("\n"); + + return { bundleJs, css, reactIife: reactVendor.iife }; +} diff --git a/apps/playground/src/pipeline/compileVueSfc.ts b/apps/playground/src/pipeline/compileVueSfc.ts deleted file mode 100644 index b51d99c0..00000000 --- a/apps/playground/src/pipeline/compileVueSfc.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc"; -import { transformTs } from "./transformTs"; - -export interface CompiledSfc { - code: string; -} - -function hashId(filename: string): string { - let hash = 5381; - for (let i = 0; i < filename.length; i++) { - hash = (hash * 33) ^ filename.charCodeAt(i); - } - return `pg-${(hash >>> 0).toString(36)}`; -} - -export async function compileVueSfc( - source: string, - filename: string, -): Promise { - const { descriptor, errors } = parse(source, { filename }); - if (errors.length > 0) { - throw new Error(errors.map((e) => e.message).join("\n")); - } - - const id = hashId(filename); - const hasScoped = descriptor.styles.some((s) => s.scoped); - - let scriptCode = ""; - let scriptLang: string | undefined; - let bindingMetadata: ReturnType["bindings"]; - if (descriptor.script || descriptor.scriptSetup) { - const compiled = compileScript(descriptor, { - id, - inlineTemplate: false, - genDefaultAs: "__sfc__", - }); - scriptCode = compiled.content; - scriptLang = - compiled.lang ?? descriptor.scriptSetup?.lang ?? descriptor.script?.lang; - bindingMetadata = compiled.bindings; - } else { - scriptCode = "const __sfc__ = {};"; - } - - let renderCode = ""; - if (descriptor.template) { - const compiled = compileTemplate({ - source: descriptor.template.content, - filename, - id, - scoped: hasScoped, - isProd: false, - compilerOptions: { bindingMetadata }, - }); - if (compiled.errors.length > 0) { - const messages = compiled.errors.map((e) => - typeof e === "string" ? e : e.message, - ); - throw new Error(messages.join("\n")); - } - renderCode = compiled.code.replace( - /export (function|const) (render|ssrRender)/, - "$1 _sfc_$2", - ); - } - - let combined = scriptCode; - combined += "\n" + renderCode + "\n"; - if (descriptor.template) { - combined += `__sfc__.render = _sfc_render;\n`; - } - combined += `__sfc__.__file = ${JSON.stringify(filename)};\n`; - combined += `export default __sfc__;\n`; - - if (scriptLang === "ts" || scriptLang === "tsx") { - combined = await transformTs(combined); - } - - return { code: combined }; -} diff --git a/apps/playground/src/pipeline/evalUserConfig.ts b/apps/playground/src/pipeline/evalUserConfig.ts index f990e940..daa30a7d 100644 --- a/apps/playground/src/pipeline/evalUserConfig.ts +++ b/apps/playground/src/pipeline/evalUserConfig.ts @@ -94,3 +94,27 @@ export async function evalUserConfig(compiledJs: string): Promise { } return candidate; } + +/** + * Evaluate a `*.styleframe.ts` extension file against the shared global + * instance. `import { styleframe } from "virtual:styleframe"` returns the same + * instance the config created, so the extension's recipe registrations mutate + * it — exactly how `@styleframe/plugin` loads extension files. + */ +export function evalStyleframeFile( + compiledJs: string, + instance: Styleframe, +): void { + const rewritten = rewriteImports(compiledJs); + const wrapped = `"use strict";\nconst __pgOutput = {};\n${rewritten}\nreturn __pgOutput;`; + const fn = new Function("__pgModules", wrapped) as ( + modules: Record, + ) => unknown; + fn({ + ...moduleShims, + "virtual:styleframe": { + styleframe: () => instance, + default: instance, + }, + }); +} diff --git a/apps/playground/src/pipeline/pipeline.ts b/apps/playground/src/pipeline/pipeline.ts index 01d91503..e36c4b5c 100644 --- a/apps/playground/src/pipeline/pipeline.ts +++ b/apps/playground/src/pipeline/pipeline.ts @@ -1,6 +1,6 @@ import { buildSrcdoc, type BuildSrcdocResult } from "./buildSrcdoc"; -import { compileVueSfc } from "./compileVueSfc"; -import { evalUserConfig } from "./evalUserConfig"; +import { bundlePreview } from "./bundlePreview"; +import { evalStyleframeFile, evalUserConfig } from "./evalUserConfig"; import { scanAndRegisterUtilities, type ScanResult, @@ -8,23 +8,21 @@ import { import { transformTs } from "./transformTs"; import { transpileStyleframe } from "./transpileStyleframe"; +/** `*.styleframe.ts` extension files are evaluated, not bundled or scanned. */ +export function isStyleframeFile(path: string): boolean { + return path.endsWith(".styleframe.ts"); +} + export interface PipelineInput { - config: string; - app: string; - card: string; - button: string; - vueUrl: string; - runtimeUrl: string; + files: Record; + configPath: string; + entryPath: string; } export interface PipelineSuccess { ok: true; css: string; runtimeTs: string; - configCode: string; - appCode: string; - cardCode: string; - buttonCode: string; srcdoc: string; scan: ScanResult; revoke: () => void; @@ -35,10 +33,10 @@ export interface PipelineFailure { stage: | "config-transform" | "config-eval" + | "styleframe" | "scan" | "transpile" - | "config-compile" - | "vue" + | "bundle" | "assemble"; error: Error; } @@ -53,9 +51,11 @@ function toError(value: unknown): Error { export async function runPipeline( input: PipelineInput, ): Promise { + const configSource = input.files[input.configPath] ?? ""; + let compiledConfig: string; try { - compiledConfig = await transformTs(input.config); + compiledConfig = await transformTs(configSource); } catch (error) { return { ok: false, stage: "config-transform", error: toError(error) }; } @@ -67,13 +67,25 @@ export async function runPipeline( return { ok: false, stage: "config-eval", error: toError(error) }; } + // Auto-include every *.styleframe.ts file: each extends the shared instance. + try { + const styleframePaths = Object.keys(input.files) + .filter((path) => path !== input.configPath && isStyleframeFile(path)) + .sort(); + for (const path of styleframePaths) { + const compiled = await transformTs(input.files[path] ?? ""); + evalStyleframeFile(compiled, instance); + } + } catch (error) { + return { ok: false, stage: "styleframe", error: toError(error) }; + } + let scan: ScanResult; try { - scan = scanAndRegisterUtilities(instance, [ - { content: input.app, filePath: "App.vue" }, - { content: input.card, filePath: "Card.vue" }, - { content: input.button, filePath: "Button.vue" }, - ]); + const sources = Object.entries(input.files) + .filter(([path]) => path !== input.configPath && !isStyleframeFile(path)) + .map(([filePath, content]) => ({ content, filePath })); + scan = scanAndRegisterUtilities(instance, sources); } catch (error) { return { ok: false, stage: "scan", error: toError(error) }; } @@ -85,36 +97,24 @@ export async function runPipeline( return { ok: false, stage: "transpile", error: toError(error) }; } - let configCode: string; - try { - configCode = await transformTs(transpiled.ts); - } catch (error) { - return { ok: false, stage: "config-compile", error: toError(error) }; - } - - let appCompiled: { code: string }; - let cardCompiled: { code: string }; - let buttonCompiled: { code: string }; + let bundled: Awaited>; try { - [appCompiled, cardCompiled, buttonCompiled] = await Promise.all([ - compileVueSfc(input.app, "App.vue"), - compileVueSfc(input.card, "Card.vue"), - compileVueSfc(input.button, "Button.vue"), - ]); + bundled = await bundlePreview({ + files: input.files, + entryPath: input.entryPath, + configPath: input.configPath, + runtimeTs: transpiled.ts, + }); } catch (error) { - return { ok: false, stage: "vue", error: toError(error) }; + return { ok: false, stage: "bundle", error: toError(error) }; } let built: BuildSrcdocResult; try { built = buildSrcdoc({ - css: transpiled.css, - configCode, - appCode: appCompiled.code, - cardCode: cardCompiled.code, - buttonCode: buttonCompiled.code, - vueUrl: input.vueUrl, - runtimeUrl: input.runtimeUrl, + css: transpiled.css + bundled.css, + bundleJs: bundled.bundleJs, + reactIife: bundled.reactIife, }); } catch (error) { return { ok: false, stage: "assemble", error: toError(error) }; @@ -124,10 +124,6 @@ export async function runPipeline( ok: true, css: transpiled.css, runtimeTs: transpiled.ts, - configCode, - appCode: appCompiled.code, - cardCode: cardCompiled.code, - buttonCode: buttonCompiled.code, srcdoc: built.srcdoc, scan, revoke: built.revoke, diff --git a/apps/playground/src/pipeline/transpileStyleframe.ts b/apps/playground/src/pipeline/transpileStyleframe.ts index 18f304b3..99ebf299 100644 --- a/apps/playground/src/pipeline/transpileStyleframe.ts +++ b/apps/playground/src/pipeline/transpileStyleframe.ts @@ -9,7 +9,10 @@ export interface StyleframeOutput { export async function transpileStyleframe( instance: Styleframe, ): Promise { - const output = await transpile(instance, { treeshake: true, scanner: true }); + const output = await transpile(instance, { + treeshake: false, + scanner: false, + }); let css = ""; let ts = ""; for (const file of output.files) { diff --git a/apps/playground/src/recipes/index.ts b/apps/playground/src/recipes/index.ts index b2ce0749..7af439f4 100644 --- a/apps/playground/src/recipes/index.ts +++ b/apps/playground/src/recipes/index.ts @@ -3,6 +3,7 @@ export * from "./useBrowserChromeRecipe"; export * from "./useEditorShellRecipe"; export * from "./useErrorBannerRecipe"; export * from "./useFileTabRecipe"; +export * from "./useFileTreeRecipe"; export * from "./useSplitPaneRecipe"; export * from "./useStatusBarRecipe"; export * from "./useTabListRecipe"; diff --git a/apps/playground/src/recipes/playground.styleframe.ts b/apps/playground/src/recipes/playground.styleframe.ts index 8128a970..f18c2b3b 100644 --- a/apps/playground/src/recipes/playground.styleframe.ts +++ b/apps/playground/src/recipes/playground.styleframe.ts @@ -2,6 +2,7 @@ import { useButtonRecipe } from "@styleframe/theme"; import { styleframe } from "virtual:styleframe"; import { useBrandBadgeRecipe, + useBrandLinkRecipe, useBrandLogotypeRecipe, useBrandMarkRecipe, useBrowserActionRecipe, @@ -13,14 +14,27 @@ import { useBrowserUrlTextRecipe, useBrowserViewportFrameRecipe, useBrowserViewportRecipe, + useEditorEmptyRecipe, useEditorShellRecipe, useEditorSurfaceRecipe, useErrorBannerCloseRecipe, useErrorBannerMessageRecipe, useErrorBannerRecipe, + useFileTabCloseRecipe, useFileTabDotRecipe, useFileTabListRecipe, useFileTabRecipe, + useFileTreeActionRecipe, + useFileTreeHeaderRecipe, + useFileTreeIconRecipe, + useFileTreeInputRecipe, + useFileTreeItemRecipe, + useFileTreeLabelRecipe, + useFileTreeListRecipe, + useFileTreeMenuItemRecipe, + useFileTreeMenuRecipe, + useFileTreeRecipe, + useFileTreeTwistyRecipe, useHmrDotRecipe, useHmrIndicatorRecipe, useSplitPaneDividerRecipe, @@ -46,6 +60,7 @@ export const pgSplitPanePane = useSplitPanePaneRecipe(s); export const pgSplitPaneDivider = useSplitPaneDividerRecipe(s); export const pgEditorShell = useEditorShellRecipe(s); export const pgEditorSurface = useEditorSurfaceRecipe(s); +export const pgEditorEmpty = useEditorEmptyRecipe(s); export const pgErrorBanner = useErrorBannerRecipe(s); export const pgErrorBannerMessage = useErrorBannerMessageRecipe(s); export const pgErrorBannerClose = useErrorBannerCloseRecipe(s); @@ -55,12 +70,26 @@ export const pgThemeToggle = useThemeToggleRecipe(s); export const button = useButtonRecipe(s); export const pgBrandMark = useBrandMarkRecipe(s); +export const pgBrandLink = useBrandLinkRecipe(s); export const pgBrandLogotype = useBrandLogotypeRecipe(s); export const pgBrandBadge = useBrandBadgeRecipe(s); export const pgFileTabList = useFileTabListRecipe(s); export const pgFileTab = useFileTabRecipe(s); export const pgFileTabDot = useFileTabDotRecipe(s); +export const pgFileTabClose = useFileTabCloseRecipe(s); + +export const pgFileTree = useFileTreeRecipe(s); +export const pgFileTreeHeader = useFileTreeHeaderRecipe(s); +export const pgFileTreeList = useFileTreeListRecipe(s); +export const pgFileTreeItem = useFileTreeItemRecipe(s); +export const pgFileTreeLabel = useFileTreeLabelRecipe(s); +export const pgFileTreeIcon = useFileTreeIconRecipe(s); +export const pgFileTreeTwisty = useFileTreeTwistyRecipe(s); +export const pgFileTreeAction = useFileTreeActionRecipe(s); +export const pgFileTreeInput = useFileTreeInputRecipe(s); +export const pgFileTreeMenu = useFileTreeMenuRecipe(s); +export const pgFileTreeMenuItem = useFileTreeMenuItemRecipe(s); export const pgBrowserChrome = useBrowserChromeRecipe(s); export const pgBrowserDots = useBrowserDotsRecipe(s); diff --git a/apps/playground/src/recipes/useBrandMarkRecipe.ts b/apps/playground/src/recipes/useBrandMarkRecipe.ts index 65d9ac9d..961ade8a 100644 --- a/apps/playground/src/recipes/useBrandMarkRecipe.ts +++ b/apps/playground/src/recipes/useBrandMarkRecipe.ts @@ -9,6 +9,16 @@ export const useBrandMarkRecipe = createUseRecipe("pg-brand-mark", { }, }); +export const useBrandLinkRecipe = createUseRecipe("pg-brand-link", { + base: { + display: "inline-flex", + alignItems: "center", + color: "inherit", + textDecoration: "none", + cursor: "pointer", + }, +}); + export const useBrandLogotypeRecipe = createUseRecipe("pg-brand-logotype", { base: { display: "block", diff --git a/apps/playground/src/recipes/useEditorShellRecipe.ts b/apps/playground/src/recipes/useEditorShellRecipe.ts index ecc53640..827846e4 100644 --- a/apps/playground/src/recipes/useEditorShellRecipe.ts +++ b/apps/playground/src/recipes/useEditorShellRecipe.ts @@ -15,6 +15,7 @@ export const useEditorShellRecipe = createUseRecipe("pg-editor-shell", { export const useEditorSurfaceRecipe = createUseRecipe("pg-editor-surface", { base: { + position: "relative", flex: "1 1 auto", minHeight: "0", overflow: "auto", @@ -24,3 +25,21 @@ export const useEditorSurfaceRecipe = createUseRecipe("pg-editor-surface", { lineHeight: "@line-height.normal", }, }); + +export const useEditorEmptyRecipe = createUseRecipe("pg-editor-empty", { + base: { + position: "absolute", + top: "0", + left: "0", + right: "0", + bottom: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "@1", + textAlign: "center", + fontSize: "@font-size.sm", + fontFamily: "@font-family.base", + color: "@color.gray-500", + }, +}); diff --git a/apps/playground/src/recipes/useFileTabRecipe.ts b/apps/playground/src/recipes/useFileTabRecipe.ts index 25aef95a..2dfc9d11 100644 --- a/apps/playground/src/recipes/useFileTabRecipe.ts +++ b/apps/playground/src/recipes/useFileTabRecipe.ts @@ -91,3 +91,32 @@ export const useFileTabDotRecipe = createUseRecipe("pg-file-tab-dot", { }, }, }); + +export const useFileTabCloseRecipe = createUseRecipe("pg-file-tab-close", { + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flex: "0 0 auto", + width: "16px", + height: "16px", + marginLeft: "@0.25", + padding: "0", + color: "@color.gray-400", + background: "transparent", + borderWidth: "@border-width.none", + borderRadius: "@border-radius.sm", + cursor: "pointer", + "&:hover": { + background: "@color.gray-200", + color: "@color.gray-900", + }, + "&:dark": { + color: "@color.gray-500", + }, + "&:dark:hover": { + background: "@color.gray-700", + color: "@color.white", + }, + }, +}); diff --git a/apps/playground/src/recipes/useFileTreeRecipe.ts b/apps/playground/src/recipes/useFileTreeRecipe.ts new file mode 100644 index 00000000..b4c351f1 --- /dev/null +++ b/apps/playground/src/recipes/useFileTreeRecipe.ts @@ -0,0 +1,269 @@ +import { createUseRecipe } from "@styleframe/theme"; + +export const useFileTreeRecipe = createUseRecipe("pg-file-tree", { + base: { + display: "flex", + flexDirection: "column", + flex: "0 0 auto", + width: "220px", + minHeight: "0", + height: "100%", + background: "@color.gray-50", + borderLeftWidth: "@border-width.thin", + borderLeftStyle: "@border-style.solid", + borderLeftColor: "@color.gray-200", + "&:dark": { + background: "@color.gray-900", + borderLeftColor: "@color.gray-800", + }, + }, +}); + +export const useFileTreeHeaderRecipe = createUseRecipe("pg-file-tree-header", { + base: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "@0.5", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.5", + fontSize: "@font-size.xs", + fontWeight: "@font-weight.medium", + textTransform: "uppercase", + color: "@color.gray-500", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "@color.gray-200", + "&:dark": { + color: "@color.gray-400", + borderBottomColor: "@color.gray-800", + }, + }, +}); + +export const useFileTreeListRecipe = createUseRecipe("pg-file-tree-list", { + base: { + display: "flex", + flexDirection: "column", + flex: "1 1 auto", + minHeight: "0", + overflowY: "auto", + paddingTop: "@0.25", + paddingBottom: "@0.25", + }, +}); + +export const useFileTreeItemRecipe = createUseRecipe("pg-file-tree-item", { + base: { + display: "flex", + alignItems: "center", + gap: "@0.375", + width: "100%", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingRight: "@0.5", + fontSize: "@font-size.sm", + fontFamily: "inherit", + color: "@color.gray-700", + background: "transparent", + borderWidth: "@border-width.none", + textAlign: "left", + whiteSpace: "nowrap", + cursor: "pointer", + "&:hover": { + background: "@color.gray-100", + }, + "&:dark": { + color: "@color.gray-300", + }, + "&:dark:hover": { + background: "@color.gray-800", + }, + }, + variants: { + state: { + inactive: {}, + active: { + background: "@color.gray-200", + color: "@color.gray-900", + "&:hover": { + background: "@color.gray-200", + }, + "&:dark": { + background: "@color.gray-800", + color: "@color.white", + }, + "&:dark:hover": { + background: "@color.gray-800", + }, + }, + }, + }, + defaultVariants: { + state: "inactive", + }, +}); + +export const useFileTreeLabelRecipe = createUseRecipe("pg-file-tree-label", { + base: { + flex: "1 1 auto", + overflow: "hidden", + textOverflow: "ellipsis", + }, +}); + +export const useFileTreeIconRecipe = createUseRecipe("pg-file-tree-icon", { + base: { + flex: "0 0 auto", + color: "@color.gray-500", + "&:dark": { + color: "@color.gray-400", + }, + }, +}); + +export const useFileTreeTwistyRecipe = createUseRecipe("pg-file-tree-twisty", { + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flex: "0 0 auto", + width: "16px", + height: "16px", + color: "@color.gray-400", + "&:dark": { + color: "@color.gray-500", + }, + }, +}); + +export const useFileTreeActionRecipe = createUseRecipe("pg-file-tree-action", { + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flex: "0 0 auto", + width: "20px", + height: "20px", + padding: "0", + color: "@color.gray-500", + background: "transparent", + borderWidth: "@border-width.none", + borderRadius: "@border-radius.sm", + cursor: "pointer", + "&:hover": { + background: "@color.gray-200", + color: "@color.gray-900", + }, + "&:dark:hover": { + background: "@color.gray-700", + color: "@color.white", + }, + }, +}); + +export const useFileTreeInputRecipe = createUseRecipe("pg-file-tree-input", { + base: { + width: "100%", + marginTop: "@0.25", + marginBottom: "@0.25", + marginLeft: "@0.5", + marginRight: "@0.5", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingLeft: "@0.5", + paddingRight: "@0.5", + fontSize: "@font-size.sm", + fontFamily: "inherit", + color: "@color.gray-900", + background: "@color.white", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "@color.primary", + borderRadius: "@border-radius.sm", + outline: "none", + "&:dark": { + color: "@color.white", + background: "@color.gray-950", + }, + }, +}); + +export const useFileTreeMenuRecipe = createUseRecipe("pg-file-tree-menu", { + base: { + position: "fixed", + zIndex: "50", + display: "flex", + flexDirection: "column", + minWidth: "168px", + paddingTop: "@0.25", + paddingBottom: "@0.25", + background: "@color.white", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "@color.gray-200", + borderRadius: "@border-radius.md", + boxShadow: "@box-shadow.lg", + "&:dark": { + background: "@color.gray-900", + borderColor: "@color.gray-700", + }, + }, +}); + +export const useFileTreeMenuItemRecipe = createUseRecipe( + "pg-file-tree-menu-item", + { + base: { + display: "flex", + alignItems: "center", + gap: "@0.5", + width: "100%", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.75", + paddingRight: "@0.75", + fontSize: "@font-size.sm", + fontFamily: "inherit", + textAlign: "left", + color: "@color.gray-700", + background: "transparent", + borderWidth: "@border-width.none", + cursor: "pointer", + whiteSpace: "nowrap", + "&:hover": { + background: "@color.gray-100", + }, + "&:dark": { + color: "@color.gray-300", + }, + "&:dark:hover": { + background: "@color.gray-800", + }, + }, + variants: { + tone: { + default: {}, + danger: { + color: "@color.error", + "&:hover": { + background: "@color.error", + color: "@color.white", + }, + "&:dark": { + color: "@color.error-300", + }, + "&:dark:hover": { + background: "@color.error", + color: "@color.white", + }, + }, + }, + }, + defaultVariants: { + tone: "default", + }, + }, +); diff --git a/apps/playground/src/samples/App.sample.tsx b/apps/playground/src/samples/App.sample.tsx new file mode 100644 index 00000000..018681b8 --- /dev/null +++ b/apps/playground/src/samples/App.sample.tsx @@ -0,0 +1,255 @@ +import "virtual:styleframe.css"; +import Avatar from "./components/Avatar/Avatar"; +import Badge from "./components/Badge/Badge"; +import Button from "./components/Button/Button"; +import Callout from "./components/Callout/Callout"; +import Card from "./components/Card/Card"; +import Checkbox from "./components/Checkbox/Checkbox"; +import Input from "./components/Input/Input"; +import Spinner from "./components/Spinner/Spinner"; + +const COLORS = [ + "primary", + "secondary", + "success", + "info", + "warning", + "error", + "neutral", + "light", + "dark", +] as const; +const BUTTON_VARIANTS = [ + "solid", + "outline", + "soft", + "subtle", + "ghost", + "link", +] as const; +const BADGE_VARIANTS = ["solid", "outline", "soft", "subtle"] as const; +const SIZES = ["xs", "sm", "md", "lg", "xl"] as const; +const CARD_VARIANTS = ["solid", "soft", "subtle"] as const; + +// Color scales generated for every base color by the design-token preset. +// (neutral / light / dark are surface colors without a 50–950 ramp.) +const PALETTE = [ + "primary", + "secondary", + "success", + "info", + "warning", + "error", + "gray", +] as const; +const LEVELS = [ + 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, + 850, 900, 950, +] as const; + +// Each chip applies exactly the utility classes printed beneath it. +const UTILITIES = [ + "_background:color.primary _color:white", + "_background:color.gray-100 _color:gray-900", + "_color:primary _font-weight:medium", + "_border-width:thin _border-style:solid _border-color:primary", +]; + +export default function App() { + return ( +
+
+
+

Styleframe UI Kit

+

+ Components, tokens, and utilities generated from{" "} + styleframe.config.ts. Edit the config or any component + on the left, then press Cmd+S (macOS) or{" "} + Ctrl+S (otherwise). +

+
+ +
+

Colors

+

+ Each base color generates a full 50–950 lightness scale. +

+ {PALETTE.map((name) => ( +
+ {name} + +
+ {LEVELS.map((level) => ( + + ))} +
+
+ ))} +
+ +
+

Buttons

+ {BUTTON_VARIANTS.map((variant) => ( +
+ {variant} + {COLORS.map((color) => ( + + ))} +
+ ))} +
+ disabled + {COLORS.map((color) => ( + + ))} +
+
+ sizes + {SIZES.map((size) => ( + + ))} +
+
+ +
+

Badges

+ {BADGE_VARIANTS.map((variant) => ( +
+ {variant} + {COLORS.map((color) => ( + + {color} + + ))} +
+ ))} +
+ +
+

Avatars

+
+ sizes + {SIZES.map((size) => ( + + SF + + ))} +
+
+ colors + + AB + + + CD + + EF + GH + + IJ + +
+
+ +
+

Callouts

+
+ + Heads up — this is an informational message. + + + Success — your changes have been saved. + + + Warning — double-check before continuing. + + + Error — something went wrong. + +
+
+ +
+

Cards

+
+ {CARD_VARIANTS.map((variant) => ( + {variant} card} + footer={} + > +

+ A {variant} card built from the card recipe with header, body, + and footer parts. +

+
+ ))} +
+
+ +
+

Form controls

+
+ input + + + + +
+
+ checkbox + Checked + Unchecked + Disabled +
+
+ +
+

Spinners

+
+ sizes + + + + +
+
+ +
+

Utility classes

+

+ Each chip is styled with the exact utility classes shown beneath it. +

+
+ {UTILITIES.map((className) => ( +
+ + Aa + + {className} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/playground/src/samples/App.sample.vue b/apps/playground/src/samples/App.sample.vue deleted file mode 100644 index d4dca376..00000000 --- a/apps/playground/src/samples/App.sample.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/apps/playground/src/samples/App.styleframe.sample.ts b/apps/playground/src/samples/App.styleframe.sample.ts new file mode 100644 index 00000000..e9d6b2b3 --- /dev/null +++ b/apps/playground/src/samples/App.styleframe.sample.ts @@ -0,0 +1,146 @@ +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); + +const { selector } = s; + +// Page scaffold +selector(".ui-kit", { + minHeight: "100vh", + background: "@color.background", + color: "@color.text", + paddingTop: "calc(@spacing * 2)", + paddingBottom: "calc(@spacing * 2)", +}); + +selector(".ui-kit-inner", { + display: "flex", + flexDirection: "column", + gap: "@spacing.2xl", + maxWidth: "60rem", + marginLeft: "auto", + marginRight: "auto", + paddingLeft: "@spacing.lg", + paddingRight: "@spacing.lg", +}); + +// Header +selector(".ui-kit-header", { + display: "flex", + flexDirection: "column", + gap: "@spacing.2xs", +}); + +selector(".ui-kit-title", { + margin: "0", + fontSize: "@font-size.2xl", + fontWeight: "@font-weight.bold", +}); + +selector(".ui-kit-subtitle", { + margin: "0", + fontSize: "@font-size.md", + color: "@color.text-weak", +}); + +// Sections +selector(".ui-kit-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.sm", +}); + +selector(".ui-kit-section-title", { + margin: "0", + paddingBottom: "@spacing.xs", + fontSize: "@font-size.lg", + fontWeight: "@font-weight.semibold", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "@color.gray-200", +}); + +selector('[data-theme="dark"] .ui-kit-section-title', { + borderBottomColor: "@color.gray-800", +}); + +selector(".ui-kit-row", { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "@spacing.sm", +}); + +selector(".ui-kit-stack", { + display: "flex", + flexDirection: "column", + gap: "@spacing.sm", +}); + +selector(".ui-kit-label", { + minWidth: "5rem", + fontSize: "@font-size.sm", + fontWeight: "@font-weight.medium", + color: "@color.text-weak", +}); + +selector(".ui-kit-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", +}); + +// Card grid — each direct child shares the available width. +selector(".ui-kit-cards", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", +}); + +selector(".ui-kit-cards > *", { + flex: "1 1 16rem", + maxWidth: "20rem", +}); + +// Token swatches +selector(".ui-kit-swatch", { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "@spacing.2xs", +}); + +selector(".ui-kit-caption", { + fontSize: "@font-size.xs", + color: "@color.text-weak", + fontFamily: "@font-family.mono", +}); + +// Color scales — one individual square swatch per level. +selector(".ui-kit-ramp-row", { + display: "flex", + alignItems: "center", + gap: "@spacing.sm", +}); + +selector(".ui-kit-ramp", { + display: "flex", + flexWrap: "wrap", + flex: "1 1 auto", + minWidth: "0", + gap: "@spacing.2xs", +}); + +selector(".ui-kit-ramp-cell", { + flex: "0 0 auto", + width: "28px", + height: "28px", + borderRadius: "@border-radius.sm", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "@color.gray-200", +}); + +selector('[data-theme="dark"] .ui-kit-ramp-cell', { + borderColor: "@color.gray-800", +}); diff --git a/apps/playground/src/samples/Avatar.sample.tsx b/apps/playground/src/samples/Avatar.sample.tsx new file mode 100644 index 00000000..c9a1f70b --- /dev/null +++ b/apps/playground/src/samples/Avatar.sample.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { avatar } from "virtual:styleframe"; + +interface AvatarProps { + color?: "primary" | "light" | "dark" | "neutral"; + variant?: "solid" | "soft"; + shape?: "circle" | "square"; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + children?: ReactNode; +} + +export default function Avatar({ + color = "neutral", + variant = "soft", + shape = "circle", + size = "md", + children, +}: AvatarProps) { + return ( +
{children}
+ ); +} diff --git a/apps/playground/src/samples/Avatar.styleframe.sample.ts b/apps/playground/src/samples/Avatar.styleframe.sample.ts new file mode 100644 index 00000000..56773c15 --- /dev/null +++ b/apps/playground/src/samples/Avatar.styleframe.sample.ts @@ -0,0 +1,19 @@ +import { styleframe } from "virtual:styleframe"; +import { useAvatarRecipe } from "@styleframe/theme"; + +const s = styleframe(); + +// Basic example configuration for the Avatar recipe. +// Edit the base styles or default variants below to customize every . +// Anything you leave out falls back to the Styleframe theme defaults. +export const avatarRecipe = useAvatarRecipe(s, { + base: { + fontWeight: "@font-weight.semibold", + }, + defaultVariants: { + color: "neutral", + variant: "soft", + shape: "circle", + size: "md", + }, +}); diff --git a/apps/playground/src/samples/Badge.sample.tsx b/apps/playground/src/samples/Badge.sample.tsx new file mode 100644 index 00000000..aa72b04a --- /dev/null +++ b/apps/playground/src/samples/Badge.sample.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import { badge } from "virtual:styleframe"; + +interface BadgeProps { + color?: + | "primary" + | "secondary" + | "success" + | "info" + | "warning" + | "error" + | "light" + | "dark" + | "neutral"; + variant?: "solid" | "outline" | "soft" | "subtle"; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + children?: ReactNode; +} + +export default function Badge({ + color = "neutral", + variant = "solid", + size = "sm", + children, +}: BadgeProps) { + // `neutral` is the recipe default, so only set color for the others. + return ( + + {children} + + ); +} diff --git a/apps/playground/src/samples/Badge.styleframe.sample.ts b/apps/playground/src/samples/Badge.styleframe.sample.ts new file mode 100644 index 00000000..183a9bd6 --- /dev/null +++ b/apps/playground/src/samples/Badge.styleframe.sample.ts @@ -0,0 +1,19 @@ +import { styleframe } from "virtual:styleframe"; +import { useBadgeRecipe } from "@styleframe/theme"; + +const s = styleframe(); + +// Basic example configuration for the Badge recipe. +// Edit the base styles or default variants below to customize every . +// Anything you leave out falls back to the Styleframe theme defaults. +export const badgeRecipe = useBadgeRecipe(s, { + base: { + fontWeight: "@font-weight.medium", + borderRadius: "@border-radius.md", + }, + defaultVariants: { + color: "neutral", + variant: "solid", + size: "sm", + }, +}); diff --git a/apps/playground/src/samples/Button.sample.tsx b/apps/playground/src/samples/Button.sample.tsx new file mode 100644 index 00000000..9b163162 --- /dev/null +++ b/apps/playground/src/samples/Button.sample.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { button } from "virtual:styleframe"; + +interface ButtonProps { + color?: + | "primary" + | "secondary" + | "success" + | "info" + | "warning" + | "error" + | "light" + | "dark" + | "neutral"; + variant?: "solid" | "outline" | "soft" | "subtle" | "ghost" | "link"; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + disabled?: boolean; + children?: ReactNode; +} + +export default function Button({ + color = "primary", + variant = "solid", + size = "md", + disabled, + children, +}: ButtonProps) { + // `neutral` is the recipe default, so only set color for the others. + return ( + + ); +} diff --git a/apps/playground/src/samples/Button.sample.vue b/apps/playground/src/samples/Button.sample.vue deleted file mode 100644 index 1ff27c9c..00000000 --- a/apps/playground/src/samples/Button.sample.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/apps/playground/src/samples/Button.styleframe.sample.ts b/apps/playground/src/samples/Button.styleframe.sample.ts new file mode 100644 index 00000000..f0c96555 --- /dev/null +++ b/apps/playground/src/samples/Button.styleframe.sample.ts @@ -0,0 +1,19 @@ +import { styleframe } from "virtual:styleframe"; +import { useButtonRecipe } from "@styleframe/theme"; + +const s = styleframe(); + +// Basic example configuration for the Button recipe. +// Edit the base styles or default variants below to customize every