Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/playground-design-system-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@styleframe/theme": patch
---

Simplify field-group recipe selector: consolidate `.input`, `.select`, `.textarea` flex-grow rules into a single joined selector.
93 changes: 49 additions & 44 deletions apps/playground/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.**

Expand All @@ -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
```

Expand All @@ -60,36 +54,45 @@ apps/playground/
| Context | Compiled by | Where CSS lives | Runtime |
|---|---|---|---|
| Shell UI | `@styleframe/plugin/vite` at dev/build time | `<style>` in parent document | `@styleframe/runtime` bundled into parent |
| User-edited preview | `esbuild-wasm` + `@styleframe/transpiler` at runtime | `<style>` inside iframe `srcdoc` | `@styleframe/runtime` loaded via iframe importmap |
| User-edited preview | `esbuild-wasm` + `@styleframe/transpiler` at runtime | `<style>` inside iframe `srcdoc` | React + `@styleframe/runtime` inlined into the preview bundle |

The iframe has `sandbox="allow-scripts allow-same-origin"` so it can fetch blob URLs and use an importmap. Cross-file imports between user files are rewritten to blob URLs:
The iframe has `sandbox="allow-scripts allow-same-origin"`. There is **no importmap**: `bundlePreview` resolves everything through an in-memory virtual-FS esbuild plugin and inlines it. React is the one exception β€” it is vendored once as an IIFE (`vite.config.ts` β†’ `buildReactVendor`) that publishes `globalThis.PGReactVendor`, runs first as a classic `<script>`, and is read by thin shims in the bundle. This keeps a single React instance and the whole preview offline.

- `./Component.vue` inside App.vue β†’ blob URL of compiled Component
- `./styleframe.config` inside Component.vue β†’ blob URL of the compiled runtime TS module (which exports `card`, `cardHeader`, etc.)
The virtual-FS plugin resolves:
- relative imports (`./Card`, `../foo/Bar`) against the in-memory `files` map (trying `.tsx/.ts/.jsx/.js/.css` + `index.*`);
- `virtual:styleframe` β†’ the generated runtime TS module (recipe functions like `card`, `button`);
- `virtual:styleframe.css` β†’ empty (the CSS is injected separately);
- `react` / `react-dom/client` / `react/jsx-runtime` / `@styleframe/runtime` β†’ vendored shims/sources.

## Pipeline Stages

`runPipeline(input)` runs these sequentially, and returns `{ ok: false, stage, error }` if any step throws:
`runPipeline(input)` runs these sequentially, and returns `{ ok: false, stage, error }` if any step throws. `input` is `{ files, configPath, entryPath }`.

| Stage | Input | Output |
|---|---|---|
| `config-transform` | user's `styleframe.config.ts` source | compiled JS |
| `config-transform` | the `configPath` file's source | compiled JS |
| `config-eval` | compiled JS | `Styleframe` instance |
| `styleframe` | every `*.styleframe.ts` file (sorted) | recipes registered on the shared instance |
| `scan` | every file except the config and `*.styleframe.ts` | utility classes registered on the instance |
| `transpile` | `Styleframe` instance | `{ css, ts }` from `@styleframe/transpiler` |
| `config-compile` | `ts` | compiled JS module for the iframe |
| `vue` | App + Component SFC sources | compiled ESM modules |
| `assemble` | all of the above + Vue/runtime URLs | `srcdoc` string |
| `bundle` | all files + `entryPath` + generated runtime TS | one ESM preview module |
| `assemble` | CSS + preview bundle + React vendor IIFE | `srcdoc` string |

`*.styleframe.ts` extension files are evaluated like `@styleframe/plugin` loads them: `import { styleframe } from "virtual:styleframe"` returns the same instance the config created (`evalStyleframeFile` shims `styleframe()` to return it), so each file's recipe registrations mutate the shared instance. They are authoring-only β€” never scanned or bundled into the preview.

`transpileStyleframe` runs with `treeshake: false` so the preview shows the **complete** generated CSS β€” the scanner cannot see runtime recipe calls, so tree-shaking would drop recipe/utility CSS the rendered components depend on.

On success, the parent assigns `iframe.srcdoc = srcdoc` and revokes the previous run's blob URLs.

## Key Conventions

1. **Shell styling is via Styleframe only.** No inline styles except for a handful of layout-critical resets in `App.vue`/`SplitPane.vue` (`flex: 1`, `min-height: 0`, etc.). All colors/spacing/borders come from the recipes in `src/recipes/`.
2. **User file specifiers.** User code imports recipes from `"./styleframe.config"` (no extension). The pipeline rewrites that to a blob URL. User code imports `Component.vue` from `"./Component.vue"` β€” same treatment.
3. **Sample files are in `src/samples/`**, imported via `?raw`. Edit these to change what the playground shows on first load.
4. **CodeMirror 6 used directly.** No wrapper library. Language is selected per file via `@codemirror/lang-javascript` (`typescript: true`) or `@codemirror/lang-vue`.
5. **Parent never evaluates untrusted code.** The user's config runs inside `new Function(...)` with a rewritten source; the Vue SFCs only run inside the iframe.
6. **The last valid preview stays on screen.** The error banner overlays the affected tab; blob URLs from the previous successful run are only revoked when the next run succeeds.
1. **Shell styling is via Styleframe only.** No inline styles except a handful of layout-critical resets in `App.vue` (`flex: 1`, `min-height: 0`). All chrome comes from the recipes in `src/recipes/`.
2. **User code mirrors a real Styleframe app.** The default project lives under `src/`, with each component in `src/components/<Name>/` paired with a `<Name>.styleframe.ts` extension file that registers its recipe(s). Components import the compiled recipes from `"virtual:styleframe"`; `styleframe.config.ts` (root) holds presets only and exports the instance as default. `src/App.tsx` is the entry β€” its default export is the preview root.
3. **Dynamic files and folders.** `state/playground.ts` keys files by path and exposes `createFile` / `createFolder` / `renameFile` / `deleteFile` / `deleteFolder` (the config and entry files, and any folder containing them, are protected). Empty folders are tracked in `state.folders`. `FileTree.vue` drives them.
4. **Sample files are in `src/samples/`** (`.tsx`, `.styleframe.sample.ts`, config), imported via `?raw`, and excluded from the shell `tsconfig` (they target the user's `virtual:styleframe`, not the shell's). The `.styleframe.sample.ts` suffix keeps them from matching the shell plugin's `**/*.styleframe.ts` glob.
5. **CodeMirror 6 used directly.** Language per file via `languageForPath` (`@codemirror/lang-javascript` with `jsx` for `.tsx`, plain `ts`, or `@codemirror/lang-css`).
6. **Parent never evaluates untrusted code.** Only the config runs in `new Function(...)`; components run only inside the iframe.
7. **The last valid preview stays on screen.** Blob URLs from the previous successful run are revoked only when the next run succeeds.

## Testing

Expand All @@ -98,11 +101,13 @@ pnpm test --filter @styleframe/playground # Vitest unit tests (jsdom)
pnpm typecheck --filter @styleframe/playground
```

Manual verification: run `pnpm dev:playground`, edit each of the three files, confirm the three right-hand tabs update, and force a syntax error to see the banner behavior.
> Adding a `pg-*` recipe requires running `pnpm dev:playground` (or `build`) once so the plugin regenerates `.styleframe/styleframe.d.ts` before `typecheck` passes.

Manual verification: run `pnpm dev:playground`, create a file from the tree, import it into `App.tsx`, press Ctrl/Cmd+S, and confirm the preview, CSS, and JS tabs update.

## Related

- `theme/src/recipes/card/` β€” recipes used by the default sample `Component.vue`.
- `engine/transpiler/src/transpile.ts` β€” `transpile(instance)` API consumed by `src/pipeline/transpileStyleframe.ts`.
- `engine/runtime/src/runtime.ts` β€” `createRecipe` consumed by the iframe's compiled config module.
- `theme/src/recipes/card/`, `theme/src/recipes/button/` β€” recipes used by the default samples.
- `engine/transpiler/src/transpile.ts` β€” `transpile(instance)` consumed by `src/pipeline/transpileStyleframe.ts`.
- `engine/runtime/src/runtime.ts` β€” `createRecipe` consumed by the preview's generated runtime module.
- `apps/storybook/` β€” mirror setup for Vite + Vue + `@styleframe/plugin`.
Loading
Loading