A family member makes pottery—clay bowls, mugs, decorative pieces. Good stuff. She wanted to show it online. Not sell it (yet), just a place where people can see what she makes. A portfolio for her creations.
She’d tried a few things already. Notion pages—works OK for organizing, but it’s not a real website. Squarespace—looks nice, but every content update still needed our son or me to get involved. The whole point was for her to manage it herself. She wants to add a new piece, write a description, pick some photos. Not learn a platform.
We could have gone with Squarespace and called it done. But knowing that Cloudflare Pages hosts static sites for free—with DDoS protection, edge caching, even AI scraping prevention—paying $15/month for a drag-and-drop builder didn’t feel right. (Credit to Cloudflare here. Offering free hosting with serious infrastructure is a brilliant moat. Can you host your own website? Sure. Protected against DDoS attacks? With significant effort. Prevent AI scraping? Good luck.) Total cost for her site: about $25/year for domain registration. That’s it.
The missing piece wasn’t hosting—it was content management. A static site is fast and free, but she’s not going to edit Markdown files and run terminal commands.
This is Birgit Studio—a native desktop app that manages pottery content and publishes it to a fast static site with one click. She opens the app, adds a creation with photos and a description, hits publish. Done. No terminal, no Markdown, no subscriptions.
Why This Exists
The problem: A family member makes pottery and wants an online presence. Not a store—a portfolio. Somewhere to point people when they ask “can I see your work?”
Existing solutions didn’t fit:
- Squarespace: She tried it. Looks professional, but every update still needed someone technical to help. $15/month for something she can’t fully manage herself
- Notion: Tried that too. Great for organizing, not a real website. No custom domain, no portfolio feel
- Etsy: Marketplace with fees, reviews, shipping expectations. She’s not running a business (yet)
- WordPress: Self-hosted complexity or WordPress.com limitations. Still heavy for the use case
The gap: The hosting problem is solved (Cloudflare Pages, free). The site framework is solved (Astro, fast). What’s missing is a way for a non-developer to manage content without touching code or learning a platform.
The goal: She opens an app, adds photos and descriptions, clicks publish. Site updates in under a minute. Total hosting cost: $0.
The Plan
The Astro site was already done—earlier that day, same session where we registered the domain. About 30 minutes to scaffold the content schema, theme it with warm pottery colors (terracotta, cream, charcoal), and deploy to Cloudflare Pages. The real project was the desktop app.
I’d seen Electrobun pop up in my RSS feed and it caught my eye. I’ve built a few Electron apps before—they work, but you’re shipping Chromium with every app. Electrobun runs on Bun instead of Node, uses native webviews, and the whole thing felt lighter. Fewer docs than Electron, smaller ecosystem—but for a quick family project, I wanted to see if it was easier to get from zero to working app.
Plan: birgit-studio-plan.md
Birgit Studio Implementation Plan
Overview
A native macOS desktop app for managing pottery content—CRUD for creations (title, description, images, categories), image editing (rotate, crop, optimize), and one-click publishing to a Cloudflare Pages site via Astro.
Goals
- Simple content management - Add, edit, delete pottery creations with rich text and multiple images
- Image handling - Pick, rotate, crop, and auto-optimize images for web
- One-click publish - Build and deploy the Astro site from the app
- Non-developer friendly - No terminal, no Markdown knowledge required
Tech Stack
| Tool | Purpose |
|---|---|
| Electrobun 1.14 | Desktop app framework (Bun-native, native webview) |
| Bun | Runtime for main process |
| React 18 | Webview UI |
| Tailwind CSS | Styling with custom pottery theme |
| TipTap | Rich text editor (Markdown under the hood) |
| sharp | Image processing (rotate, crop, WebP optimization) |
| gray-matter | Markdown frontmatter parsing |
| Vite | Webview bundling with HMR |
Architecture: Two-Process Model
Electrobun uses a two-process architecture:
- Main process (Bun): File I/O, native dialogs, image processing, deploy pipeline
- Webview (React): UI rendered in native webview, communicates via typed RPC
All communication is defined in a single StudioRPC type:
type StudioRPC = {
bun: RPCSchema<{
requests: {
listCreations: { params: {}; response: { creations: CreationSummary[] } };
saveCreation: { params: { creation: Creation }; response: { success: boolean } };
publish: { params: {}; response: { success: boolean; error?: string } };
applyImageEdit: {
params: { webPath: string; rotation: number; crop: { x: number; y: number; size: number } | null };
response: { dataUrl: string | null };
};
// ... 15 more request types
};
messages: {
openProjectPicker: {};
pickImages: {};
};
}>;
webview: RPCSchema<{
messages: {
deployProgress: { message: string; phase: "build" | "deploy" | "done" | "error" };
projectPicked: { path: string | null };
imagesPicked: { paths: string[] };
};
}>;
};
Implementation Tasks
Task 1: App Scaffold
- Electrobun config, Vite setup, Tailwind with pottery theme
- Main process bootstrap with window creation and RPC wiring
- React app shell with view routing (setup → list → editor → settings)
Task 2: Content Management
- ContentManager: CRUD for Markdown files with YAML frontmatter
- Creation model: slug, title, description, category, images, price, status
- Rich text editor via TipTap (renders to Markdown)
Task 3: Image Pipeline
- ImageManager: Native file picker, copy to project
- ImageProcessor: Rotate, square crop via sharp
- Non-destructive editing with original backup and revert
- Auto-optimize to WebP on publish
Task 4: Site Settings
- Category CRUD (pottery categories like bowls, mugs, decorative)
- About page editor
- Site identity management (name, description)
Task 5: Publish Pipeline
- Optimize all images (sharp → WebP)
- Rewrite image references in Markdown
- Run Astro build
- Deploy via Wrangler to Cloudflare Pages
- Auto-commit and push to git
- Stream progress to UI via RPC messages
Task 6: Polish
- macOS application menu with keyboard shortcuts
- About dialog
- App icon
- Code signing and notarization
Project Structure
birgit-studio/
├── electrobun.config.ts # App config, code signing
├── src/
│ ├── bun/ # Main process (Bun runtime)
│ │ ├── index.ts # Bootstrap, window, menu, RPC
│ │ ├── rpc-handlers.ts # All RPC handler implementations
│ │ ├── content-manager.ts # Markdown CRUD (gray-matter)
│ │ ├── image-manager.ts # Image file management
│ │ ├── image-processor.ts # Rotate, crop, optimize (sharp)
│ │ ├── deployer.ts # Build + deploy pipeline
│ │ ├── site-manager.ts # Site config read/write
│ │ ├── settings.ts # App settings persistence
│ │ ├── dev-server.ts # Local preview server
│ │ └── types/
│ │ ├── rpc.ts # StudioRPC type definition
│ │ └── content.ts # Creation, SiteConfig types
│ └── mainview/ # Webview (React)
│ ├── App.tsx # View routing
│ ├── components/
│ │ ├── CreationList.tsx # Grid of creations
│ │ ├── CreationEditor.tsx # Edit form with TipTap
│ │ ├── ImageManager.tsx # Image gallery + picker
│ │ ├── ImageEditor.tsx # Rotate/crop UI
│ │ ├── SiteSettings.tsx # Categories, about, identity
│ │ ├── Toolbar.tsx # Preview + publish buttons
│ │ └── AboutDialog.tsx # Version info
│ ├── hooks/
│ │ ├── useCreations.ts # Creation state management
│ │ └── useRPC.ts # RPC bridge + native dialog wrappers
│ └── lib/
│ └── editor.ts # TipTap configuration
Trade-offs
Electrobun vs Electron
- Chose Electrobun for native webview (no bundled Chromium) and Bun runtime
- Trade-off: Much less documentation, smaller ecosystem
- Benefit: Lighter binary, faster startup, Bun APIs for file I/O
Local-First vs Cloud CMS
- Chose local files (Markdown + images on disk) over a database or CMS API
- Trade-off: No collaboration, single-machine access
- Benefit: Zero infrastructure, works offline, content is plain files
TipTap vs Plain Textarea
- Chose TipTap for rich text editing that renders to Markdown
- Trade-off: Heavier dependency, more complexity
- Benefit: Non-developer can format text without learning Markdown syntax
How Claude Code Actually Worked
Site was already deployed, scope was clear: make a desktop app that reads and writes to that project’s content directory. Whole build took about four hours.
The biggest time sink wasn’t the app logic—it was Claude learning Electrobun. Electrobun is newer, less documented, and Claude’s training data doesn’t have years of Stack Overflow answers to draw from. The API is different from Electron in ways that aren’t always obvious. No ipcMain/ipcRenderer. Instead, you define a typed RPC schema and Electrobun generates the communication layer. Requests go one way (webview asks Bun for data), fire-and-forget messages go the other (Bun tells webview something happened). The typed contract is actually nicer than Electron’s stringly-typed IPC—but Claude had to figure that out from the docs, not from memory.
Native dialogs were the first surprise. In Electron, you call dialog.showOpenDialog() and get a promise back. In Electrobun, you can’t do that from the webview—native dialogs are a main-process concern. So the pattern becomes: webview sends a fire-and-forget message (“open a file picker”), main process opens the dialog, then sends the result back as another message. I wrapped this in a Promise on the webview side so the calling code still looks synchronous.
Content management came together quickly—gray-matter for frontmatter parsing, sharp for image processing, nothing exotic. The publish pipeline chains: optimize images → rewrite Markdown references → Astro build → Wrangler deploy → git commit + push. All progress streams back to the UI through RPC messages so she can see what’s happening.
The last stretch was polish—macOS application menu with proper keyboard shortcuts (Cmd+N for new creation, Cmd+W to close), an About dialog, app icon, code signing, and notarization. Electrobun handles signing and notarization through its config, which saved time compared to doing it manually with codesign and notarytool.
Twelve commits, start to finish. About four hours of active work.
Fun Challenges: Learning Electrobun
Most of the friction was Electrobun-specific. The app logic (content CRUD, image processing, deploy pipeline) was pretty standard. But Electrobun does things differently from Electron, and Claude had to learn those differences in real time.
The RPC Pattern Shift
Coming from Electron, I expected ipcMain.handle() and ipcRenderer.invoke(). Electrobun’s RPC is completely different—you define a schema type, and both sides get typed send/receive methods generated from it. The webview calls electroview.rpc.request.listCreations({}) and gets back { creations: CreationSummary[] }. Type-safe, auto-completed, no string-based channel names.
But fire-and-forget messages (like “open a file picker”) don’t have built-in request/response semantics. You send a message one way, and the result comes back as a different message. Had to build a Promise wrapper:
export function openProjectPicker(): Promise<string | null> {
return new Promise((resolve) => {
projectPickedCallback = (data) => {
projectPickedCallback = null;
resolve(data.path);
};
electroview.rpc.send.openProjectPicker({});
});
}
Not complicated once you see it. But Claude’s first instinct was to write Electron-style await dialog.showOpenDialog() from the webview, which doesn’t exist in Electrobun. Took a couple of iterations to land on this pattern.
Application Menu: Order Matters
First attempt at the application menu: nothing appeared. Window showed up, default macOS menu bar. Turns out Electrobun requires ApplicationMenu.setApplicationMenu() before creating the BrowserWindow. Electron doesn’t care about ordering. One-line move, but Claude had to read the Electrobun source to figure it out—the docs don’t mention this.
Non-Destructive Image Editing
First implementation of rotate and crop overwrote the source file. Bad idea when she crops wrong and wants to undo. Solution: on first edit, copy the original to a .original backup. All subsequent edits process from the original, not the already-edited version. Revert just restores from the backup.
Streaming Publish Progress
The publish flow runs four sequential steps (optimize → build → deploy → git sync), each taking several seconds. Without feedback, it looks frozen. Every step calls onProgress(message, phase) which the main process forwards to the webview via rpc.send.deployProgress(). Even the image optimizer reports per-file progress so she knows it’s working, not stuck.
Results
Build time: ~4 hours (12 commits), plus 30 minutes for the Astro site earlier
First launch: Pointed it at the Astro project, it found the content directory. Added a test creation with a phone photo of a bowl. Hit publish. Site updated in about 40 seconds—image optimized to WebP, Astro built, Wrangler deployed. Refreshed the live URL and there it was.
Her reaction: “Woohoooooooo.” She’s added her creations, picked and cropped photos, and published multiple times since. The site is live with real pottery content. The workflow is exactly what I hoped: open app, add content, publish. No one else needs to be involved.
What it handles: Content CRUD with rich text editing, image management (pick, rotate, crop, auto-optimize to WebP), one-click publish to Cloudflare Pages with streaming progress, site-wide settings, local preview, and macOS native menus with keyboard shortcuts. Auto git commit and push after every publish.
What it doesn’t handle: Windows/Linux (untested), multi-user collaboration, and no version history beyond image revert.
What’s next: Nothing. It works. She has her pottery online, she can update it herself, and it costs nothing to host. If she wants to sell pieces later, I’ll add pricing features. Until then, it’s done.
Tech stack: Electrobun 1.14, Bun, React 18, Tailwind CSS, TipTap, sharp, Vite Build time: ~4 hours (app) + 30 min (Astro site) Lines of code: ~2,900 across 25 modules