@dfosco/storyboard-core 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,11 +13,14 @@
13
13
  ],
14
14
  "exports": {
15
15
  ".": "./src/index.js",
16
+ "./vite/server": "./src/vite/server-plugin.js",
16
17
  "./comments": "./src/comments/index.js",
17
- "./comments/ui/comments.css": "./src/comments/ui/comments.css"
18
+ "./comments/ui/comments.css": "./src/comments/ui/comments.css",
19
+ "./workshop/ui/mount.js": "./src/workshop/ui/mount.js"
18
20
  },
19
21
  "dependencies": {
20
22
  "alpinejs": "^3.15.8",
23
+ "jsonc-parser": "^3.3.1",
21
24
  "tachyons": "^4.12.0"
22
25
  }
23
26
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Storyboard Server Plugin — core dev-server infrastructure.
3
+ *
4
+ * Always-on Vite plugin that mounts a middleware backbone at `/_storyboard/`.
5
+ * Reads `storyboard.config.json` for workshop features and plugin config.
6
+ * Workshop API routes are wired directly; plugins register via the registry.
7
+ *
8
+ * Usage in vite.config.js:
9
+ * import storyboardServer from '@dfosco/storyboard-core/vite/server'
10
+ * storyboardServer() // reads storyboard.config.json, no args needed
11
+ */
12
+
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { parse as parseJsonc } from 'jsonc-parser'
16
+ import { features as workshopFeatures } from '../workshop/features/registry.js'
17
+
18
+ const API_PREFIX = '/_storyboard/'
19
+
20
+ /**
21
+ * Parse JSON request body from an IncomingMessage.
22
+ */
23
+ function parseJsonBody(req) {
24
+ return new Promise((resolve, reject) => {
25
+ let body = ''
26
+ req.on('data', (chunk) => { body += chunk })
27
+ req.on('end', () => {
28
+ if (!body) return resolve({})
29
+ try { resolve(JSON.parse(body)) }
30
+ catch { reject(new Error('Invalid JSON body')) }
31
+ })
32
+ req.on('error', reject)
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Send a JSON response.
38
+ */
39
+ function sendJson(res, status, data) {
40
+ res.writeHead(status, { 'Content-Type': 'application/json' })
41
+ res.end(JSON.stringify(data))
42
+ }
43
+
44
+ /**
45
+ * Read storyboard.config.json from the project root.
46
+ */
47
+ function readConfig(root) {
48
+ const configPath = path.join(root, 'storyboard.config.json')
49
+ if (!fs.existsSync(configPath)) return {}
50
+ try {
51
+ const raw = fs.readFileSync(configPath, 'utf-8')
52
+ return parseJsonc(raw) || {}
53
+ } catch {
54
+ return {}
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if any workshop feature is enabled.
60
+ */
61
+ function hasAnyWorkshopFeature(workshopConfig) {
62
+ if (!workshopConfig?.features) return false
63
+ return Object.values(workshopConfig.features).some(Boolean)
64
+ }
65
+
66
+ /**
67
+ * Core storyboard server Vite plugin.
68
+ */
69
+ export default function storyboardServer() {
70
+ let root = ''
71
+ let base = '/'
72
+ let config = {}
73
+
74
+ // Route handler registry — plugins register here during setup
75
+ const routeHandlers = new Map()
76
+ const clientScripts = []
77
+
78
+ return {
79
+ name: 'storyboard-server',
80
+
81
+ configResolved(viteConfig) {
82
+ root = viteConfig.root
83
+ base = viteConfig.base || '/'
84
+ config = readConfig(root)
85
+ },
86
+
87
+ configureServer(server) {
88
+ const workshopConfig = config.workshop || {}
89
+
90
+ // If workshop is explicitly disabled, skip everything
91
+ if (workshopConfig.enabled === false) return
92
+
93
+ const enabledFeatures = workshopConfig.features || {}
94
+
95
+ // Wire workshop API routes for each enabled feature
96
+ for (const [featureName, featureModule] of Object.entries(workshopFeatures)) {
97
+ if (enabledFeatures[featureName] === false) continue
98
+ if (featureModule.serverSetup) {
99
+ const templatesDir = path.resolve(
100
+ path.dirname(new URL(import.meta.url).pathname),
101
+ `../workshop/features/${featureName}/templates`
102
+ )
103
+ routeHandlers.set('workshop', featureModule.serverSetup({ root, sendJson }, templatesDir))
104
+ }
105
+ }
106
+
107
+ // Inject workshop client UI when any feature is enabled
108
+ if (hasAnyWorkshopFeature(workshopConfig)) {
109
+ // Resolve the actual filesystem path for the mount script.
110
+ // Use /@fs/ prefix so Vite serves it through its module pipeline.
111
+ const mountPath = path.resolve(
112
+ path.dirname(new URL(import.meta.url).pathname),
113
+ '../workshop/ui/mount.js'
114
+ )
115
+ clientScripts.push('/@fs' + mountPath)
116
+ }
117
+
118
+ // Plugin registry for external plugins (future use).
119
+ // Plugins call registerRoutes/registerClientScript in their setup().
120
+ // const pluginCtx = { server, root, config, registerRoutes, registerClientScript }
121
+ // Future: auto-discover and initialize plugins from pluginsConfig here
122
+
123
+ // Mount the /_storyboard/ middleware router
124
+ // Vite's dev server strips the base path from req.url for middleware,
125
+ // but the base-redirect plugin may redirect bare URLs first.
126
+ // We check both with and without base prefix.
127
+ server.middlewares.use(async (req, res, next) => {
128
+ if (!req.url) return next()
129
+
130
+ // Strip base path if present to normalize the URL
131
+ let url = req.url
132
+ const baseNoTrail = base.replace(/\/$/, '')
133
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
134
+ url = url.slice(baseNoTrail.length) || '/'
135
+ }
136
+
137
+ if (!url.startsWith(API_PREFIX)) return next()
138
+
139
+ // Parse: /_storyboard/{prefix}/{rest}
140
+ const pathAfterPrefix = url.slice(API_PREFIX.length)
141
+ const slashIndex = pathAfterPrefix.indexOf('/')
142
+ const prefix = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex)
143
+ const restPath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex)
144
+
145
+ const handler = routeHandlers.get(prefix)
146
+ if (!handler) {
147
+ sendJson(res, 404, { error: `No handler registered for prefix: ${prefix}` })
148
+ return
149
+ }
150
+
151
+ try {
152
+ let body = {}
153
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
154
+ body = await parseJsonBody(req)
155
+ }
156
+ await handler(req, res, { body, path: restPath, method: req.method })
157
+ } catch (err) {
158
+ console.error(`[storyboard-server] Error in ${prefix}:`, err)
159
+ sendJson(res, 500, { error: err.message || 'Internal server error' })
160
+ }
161
+ })
162
+ },
163
+
164
+ transformIndexHtml() {
165
+ if (clientScripts.length === 0) return []
166
+
167
+ return clientScripts.map((src) => ({
168
+ tag: 'script',
169
+ attrs: { type: 'module', src: base + src.replace(/^\//, '') },
170
+ injectTo: 'body',
171
+ }))
172
+ },
173
+ }
174
+ }
175
+
176
+ export { sendJson }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Create Page form — Alpine.js component for the Workshop Dev Panel.
3
+ * Handles page name input, validation preview, and API submission.
4
+ */
5
+
6
+ /**
7
+ * Register the createPageForm Alpine.js data component.
8
+ */
9
+ export function registerCreatePageForm(Alpine) {
10
+ Alpine.data('createPageForm', () => ({
11
+ name: '',
12
+ createScene: true,
13
+ template: 'blank',
14
+ submitting: false,
15
+ error: null,
16
+ success: null,
17
+
18
+ get pascalName() {
19
+ return this.name
20
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
21
+ .split(/[\s_-]+/)
22
+ .filter(Boolean)
23
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
24
+ .join('')
25
+ },
26
+
27
+ get routePreview() {
28
+ return this.pascalName ? `/${this.pascalName}` : ''
29
+ },
30
+
31
+ async submit() {
32
+ if (!this.pascalName || this.submitting) return
33
+ this.submitting = true
34
+ this.error = null
35
+ this.success = null
36
+
37
+ try {
38
+ // Build API URL with the app's base path
39
+ const basePath = document.querySelector('base')?.getAttribute('href') || '/'
40
+ const apiUrl = basePath.replace(/\/$/, '') + '/_storyboard/workshop/pages'
41
+
42
+ const res = await fetch(apiUrl, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({
46
+ name: this.name,
47
+ template: this.template,
48
+ createScene: this.createScene,
49
+ }),
50
+ })
51
+ const data = await res.json()
52
+
53
+ if (!res.ok) {
54
+ this.error = data.error || 'Failed to create page'
55
+ return
56
+ }
57
+
58
+ this.success = `Created ${data.path}`
59
+ this.name = ''
60
+
61
+ // Navigate after HMR picks up the new file
62
+ setTimeout(() => {
63
+ const base = document.querySelector('base')?.href || '/'
64
+ window.location.href = base + data.route.slice(1)
65
+ }, 1500)
66
+ } catch (err) {
67
+ this.error = err.message || 'Network error'
68
+ } finally {
69
+ this.submitting = false
70
+ }
71
+ },
72
+ }))
73
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pages feature — create new pages from the Workshop Dev Panel.
3
+ *
4
+ * Each workshop feature exports a standard interface:
5
+ * - name: Feature identifier (matches config key in workshop.features)
6
+ * - label: Display name for the menu item
7
+ * - icon: Emoji or HTML for the menu icon
8
+ * - overlayId: Unique ID for the overlay (used by x-if in mount.js)
9
+ * - serverSetup: Called by the server plugin to register API routes
10
+ * - clientSetup: Called by mount.js to register Alpine components
11
+ * - overlayHtml: Returns the overlay HTML string for the create form
12
+ */
13
+
14
+ export { createPagesHandler as serverSetup } from './server.js'
15
+ export { registerCreatePageForm as clientSetup } from './client.js'
16
+
17
+ export const name = 'createPage'
18
+ export const label = 'Create page'
19
+ export const icon = '📄'
20
+ export const overlayId = 'createPage'
21
+
22
+ export function overlayHtml() {
23
+ return `
24
+ <div class="sb-workshop-modal sb-bg ba sb-b-default br3 sb-shadow" x-data="createPageForm">
25
+ <div class="flex items-center justify-between ph4 pv3 bb sb-b-muted">
26
+ <h2 class="ma0 f5 fw6 sb-fg">Create page</h2>
27
+ <button class="sb-workshop-close-btn bg-transparent bn br2 sb-fg-muted pointer" @click="$dispatch('close-overlay')" aria-label="Close">&times;</button>
28
+ </div>
29
+ <div class="pa4" @close-overlay.window="closeOverlay()">
30
+ <label class="db mb1 fw5 sb-fg f6" for="sb-workshop-page-name">Page name</label>
31
+ <input class="sb-input w-100 ph3 pv2 br2 f6 db mb2" id="sb-workshop-page-name" type="text"
32
+ placeholder="e.g. Dashboard, User Settings" autocomplete="off" spellcheck="false"
33
+ x-model="name" @keydown.enter="submit()" />
34
+
35
+ <div class="mb3 f7 sb-fg-muted lh-copy" x-show="routePreview">
36
+ Route: <code class="dib ph1 sb-bg-muted br1 code sb-fg" x-text="routePreview"></code>
37
+ </div>
38
+
39
+ <label class="flex items-center mb3 pointer sb-fg f6">
40
+ <input type="checkbox" class="mr2" x-model="createScene" />
41
+ Create scene file
42
+ </label>
43
+
44
+ <template x-if="error">
45
+ <div class="mb3 ph3 pv2 br2 sb-fg-danger f7 sb-error-bg" x-text="error"></div>
46
+ </template>
47
+ <template x-if="success">
48
+ <div class="mb3 ph3 pv2 br2 sb-fg-success f7" x-text="success"></div>
49
+ </template>
50
+
51
+ <div class="flex justify-end">
52
+ <button class="sb-workshop-btn sb-workshop-btn-secondary mr2" @click="$dispatch('close-overlay')">Cancel</button>
53
+ <button class="sb-workshop-btn sb-workshop-btn-primary" @click="submit()" :disabled="!pascalName || submitting"
54
+ x-text="submitting ? 'Creating…' : 'Create'"></button>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ `
59
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Workshop API — page creation and listing.
3
+ *
4
+ * Routes (mounted at /_storyboard/workshop/):
5
+ * GET /pages — list existing pages
6
+ * POST /pages — create a new page (+ optional scene)
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ const SCENE_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
13
+
14
+ /**
15
+ * Convert a raw name to PascalCase for use as component name + filename.
16
+ * "my cool page" → "MyCoolPage"
17
+ */
18
+ function toPascalCase(str) {
19
+ return str
20
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
21
+ .split(/[\s_-]+/)
22
+ .filter(Boolean)
23
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
24
+ .join('')
25
+ }
26
+
27
+ /**
28
+ * Validate a page name.
29
+ */
30
+ function validatePageName(name) {
31
+ if (!name || typeof name !== 'string') {
32
+ return { valid: false, error: 'Page name is required' }
33
+ }
34
+
35
+ const pascalName = toPascalCase(name.trim())
36
+
37
+ if (!pascalName) {
38
+ return { valid: false, error: 'Page name must contain at least one alphanumeric character' }
39
+ }
40
+
41
+ if (/^[0-9]/.test(pascalName)) {
42
+ return { valid: false, error: 'Page name cannot start with a number' }
43
+ }
44
+
45
+ const reserved = ['_app', 'index', 'App', 'Index']
46
+ if (reserved.includes(pascalName)) {
47
+ return { valid: false, error: `"${pascalName}" is a reserved page name` }
48
+ }
49
+
50
+ return { valid: true, pascalName }
51
+ }
52
+
53
+ /**
54
+ * Read a template file and replace {{PageName}} placeholders.
55
+ */
56
+ function renderTemplate(templatesDir, templateName, pageName) {
57
+ const tplPath = path.join(templatesDir, `${templateName}.html`)
58
+ if (!fs.existsSync(tplPath)) {
59
+ throw new Error(`Template not found: ${templateName}`)
60
+ }
61
+ const tpl = fs.readFileSync(tplPath, 'utf-8')
62
+ return tpl.replaceAll('{{PageName}}', pageName)
63
+ }
64
+
65
+ /**
66
+ * List all existing page files in src/pages/.
67
+ */
68
+ function listPages(root) {
69
+ const pagesDir = path.join(root, 'src', 'pages')
70
+ if (!fs.existsSync(pagesDir)) return []
71
+
72
+ return fs.readdirSync(pagesDir)
73
+ .filter((f) => f.endsWith('.jsx') && !f.startsWith('_'))
74
+ .map((f) => {
75
+ const name = f.replace('.jsx', '')
76
+ const route = name === 'index' ? '/' : `/${name}`
77
+ return { name, file: f, route }
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Create the pages API route handler.
83
+ * @param {object} ctx - Server context ({ root, sendJson })
84
+ * @param {string} templatesDir - Absolute path to templates directory
85
+ */
86
+ export function createPagesHandler(ctx, templatesDir) {
87
+ const { root, sendJson } = ctx
88
+
89
+ return async (req, res, { body, path: routePath, method }) => {
90
+ if (routePath === '/pages' && method === 'GET') {
91
+ const pages = listPages(root)
92
+ sendJson(res, 200, { pages })
93
+ return
94
+ }
95
+
96
+ if (routePath === '/pages' && method === 'POST') {
97
+ const { name, template = 'blank', createScene = true } = body
98
+
99
+ const validation = validatePageName(name)
100
+ if (!validation.valid) {
101
+ sendJson(res, 400, { error: validation.error })
102
+ return
103
+ }
104
+
105
+ const { pascalName } = validation
106
+ const pagesDir = path.join(root, 'src', 'pages')
107
+ const pagePath = path.join(pagesDir, `${pascalName}.jsx`)
108
+
109
+ if (fs.existsSync(pagePath)) {
110
+ sendJson(res, 409, { error: `Page "${pascalName}" already exists` })
111
+ return
112
+ }
113
+
114
+ let content
115
+ try {
116
+ content = renderTemplate(templatesDir, template, pascalName)
117
+ } catch (err) {
118
+ sendJson(res, 400, { error: err.message })
119
+ return
120
+ }
121
+
122
+ fs.mkdirSync(pagesDir, { recursive: true })
123
+ fs.writeFileSync(pagePath, content, 'utf-8')
124
+
125
+ const result = {
126
+ success: true,
127
+ path: `src/pages/${pascalName}.jsx`,
128
+ route: `/${pascalName}`,
129
+ }
130
+
131
+ if (createScene) {
132
+ const dataDir = path.join(root, 'src', 'data')
133
+ const scenePath = path.join(dataDir, `${pascalName}.scene.json`)
134
+
135
+ if (!fs.existsSync(scenePath)) {
136
+ fs.mkdirSync(dataDir, { recursive: true })
137
+ fs.writeFileSync(scenePath, SCENE_SKELETON, 'utf-8')
138
+ result.scenePath = `src/data/${pascalName}.scene.json`
139
+ }
140
+ }
141
+
142
+ sendJson(res, 201, result)
143
+ return
144
+ }
145
+
146
+ sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
147
+ }
148
+ }
@@ -0,0 +1,9 @@
1
+ import { useSceneData } from '@dfosco/storyboard-react'
2
+
3
+ export default function {{PageName}}() {
4
+ return (
5
+ <main className="pa4">
6
+ <h1>{{PageName}}</h1>
7
+ </main>
8
+ )
9
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Workshop feature registry.
3
+ *
4
+ * Maps feature names (matching keys in storyboard.config.json → workshop.features)
5
+ * to their module paths. The server plugin and client mount use this registry
6
+ * to dynamically load only the enabled features.
7
+ *
8
+ * To add a new feature:
9
+ * 1. Create a directory under features/ with index.js exporting the standard interface
10
+ * 2. Add its import here
11
+ */
12
+
13
+ import * as createPage from './createPage/index.js'
14
+
15
+ /**
16
+ * All available workshop features, keyed by config name.
17
+ */
18
+ export const features = {
19
+ createPage,
20
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Workshop Dev Panel — floating button + menu + overlays.
3
+ *
4
+ * Uses Alpine.js for reactivity and Tachyons + sb-* tokens for styling.
5
+ * Injected into the page by the storyboard server plugin via transformIndexHtml.
6
+ *
7
+ * Features are loaded from the registry and rendered dynamically based on
8
+ * which features are enabled in storyboard.config.json → workshop.features.
9
+ */
10
+
11
+ import Alpine from 'alpinejs'
12
+ import './workshop.css'
13
+ import { features as allFeatures } from '../features/registry.js'
14
+
15
+ const WRENCH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M5.433 2.304A4.494 4.494 0 0 0 3.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64v.008l-.164 3.337a.75.75 0 1 1-1.498-.073l.163-3.34c.007-.14-.1-.313-.36-.465A5.986 5.986 0 0 1 2 6a5.994 5.994 0 0 1 2.567-4.92 1.482 1.482 0 0 1 1.673-.04c.462.296.76.827.76 1.423v2.076c0 .332.214.572.491.572.268 0 .492-.24.492-.572V2.463c0-.596.298-1.127.76-1.423a1.482 1.482 0 0 1 1.673.04A5.994 5.994 0 0 1 13 6a5.986 5.986 0 0 1-2.633 4.909c-.26.152-.367.325-.36.465l.164 3.34a.75.75 0 1 1-1.498.073l-.164-3.337v-.008c-.027-.717.384-1.312.902-1.64A4.494 4.494 0 0 0 11.5 6a4.494 4.494 0 0 0-1.933-3.696c-.024.017-.067.067-.067.159v2.076c0 1.074-.84 2.072-1.991 2.072-1.161 0-2.009-.998-2.009-2.072V2.463c0-.092-.043-.142-.067-.16Z"></path></svg>`
16
+
17
+ let _mounted = false
18
+
19
+ /**
20
+ * Resolve which features are enabled from a data attribute on the script tag,
21
+ * injected by the server plugin. Falls back to all features enabled.
22
+ */
23
+ function getEnabledFeatures() {
24
+ const script = document.querySelector('script[data-workshop-features]')
25
+ if (script) {
26
+ try { return JSON.parse(script.dataset.workshopFeatures) } catch { /* ignore */ }
27
+ }
28
+ // Fallback: enable all registered features
29
+ return Object.fromEntries(Object.keys(allFeatures).map((k) => [k, true]))
30
+ }
31
+
32
+ export function mountWorkshop() {
33
+ if (_mounted) return
34
+ _mounted = true
35
+
36
+ const enabledConfig = getEnabledFeatures()
37
+ const enabledFeatures = Object.entries(allFeatures)
38
+ .filter(([name]) => enabledConfig[name] !== false)
39
+
40
+ // Register Alpine panel component
41
+ Alpine.data('workshopPanel', () => ({
42
+ open: false,
43
+ overlay: null,
44
+ toggle() { this.open = !this.open },
45
+ close() { this.open = false },
46
+ showOverlay(name) {
47
+ this.overlay = name
48
+ this.open = false
49
+ },
50
+ closeOverlay() { this.overlay = null },
51
+ }))
52
+
53
+ // Let each enabled feature register its Alpine components
54
+ for (const [, feature] of enabledFeatures) {
55
+ if (feature.clientSetup) feature.clientSetup(Alpine)
56
+ }
57
+
58
+ // Build menu items from enabled features
59
+ const menuItems = enabledFeatures
60
+ .filter(([, f]) => f.label && f.overlayId)
61
+ .map(([, f]) => `
62
+ <button class="sb-workshop-menu-item" @click="showOverlay('${f.overlayId}')">
63
+ <span class="sb-workshop-menu-icon">${f.icon || ''}</span> ${f.label}
64
+ </button>
65
+ `).join('')
66
+
67
+ // Build overlay templates from enabled features
68
+ const overlays = enabledFeatures
69
+ .filter(([, f]) => f.overlayId && f.overlayHtml)
70
+ .map(([, f]) => `
71
+ <template x-if="overlay === '${f.overlayId}'">
72
+ <div class="sb-workshop-backdrop" @click.self="closeOverlay()">
73
+ ${f.overlayHtml()}
74
+ </div>
75
+ </template>
76
+ `).join('')
77
+
78
+ const container = document.createElement('div')
79
+ container.id = 'sb-workshop'
80
+ container.innerHTML = `
81
+ <div class="sb-workshop-wrapper" x-data="workshopPanel">
82
+ <button class="sb-workshop-trigger" @click="toggle()" aria-label="Workshop" title="Workshop">
83
+ ${WRENCH_ICON}
84
+ </button>
85
+
86
+ <div class="sb-workshop-menu" x-show="open" x-transition @click.outside="close()">
87
+ <div class="sb-workshop-menu-header">Workshop</div>
88
+ ${menuItems}
89
+ <div class="sb-workshop-hint">Dev-only tools</div>
90
+ </div>
91
+
92
+ ${overlays}
93
+ </div>
94
+ `
95
+
96
+ document.body.appendChild(container)
97
+ Alpine.initTree(container)
98
+
99
+ // Cmd+. / Ctrl+. toggles workshop visibility (matches devtools shortcut)
100
+ const wrapper = container.querySelector('.sb-workshop-wrapper')
101
+ let visible = true
102
+ window.addEventListener('keydown', (e) => {
103
+ if (e.key === '.' && (e.metaKey || e.ctrlKey)) {
104
+ visible = !visible
105
+ wrapper.style.display = visible ? '' : 'none'
106
+ }
107
+ })
108
+ }
109
+
110
+ // Auto-mount
111
+ mountWorkshop()
@@ -0,0 +1,160 @@
1
+ /*
2
+ Workshop Dev Panel styles.
3
+ Uses Tachyons utilities + sb-* custom properties from the storyboard theme system.
4
+ */
5
+
6
+ /* --- Wrapper & trigger --- */
7
+ .sb-workshop-wrapper {
8
+ position: fixed;
9
+ bottom: 24px;
10
+ right: 76px; /* offset to sit left of the DevTools beaker (24 + 48 + 4) */
11
+ z-index: 9999;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
13
+ }
14
+
15
+ .sb-workshop-trigger {
16
+ display: flex;
17
+ align-items: center;
18
+ padding: 12px;
19
+ background-color: var(--sb-bg, #161b22);
20
+ color: var(--sb-fg-muted, #8b949e);
21
+ border: 1px solid var(--sb-border, #30363d);
22
+ border-radius: 50%;
23
+ cursor: pointer;
24
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
25
+ transition: opacity 150ms ease, transform 150ms ease;
26
+ user-select: none;
27
+ }
28
+ .sb-workshop-trigger:hover { transform: scale(1.05); }
29
+ .sb-workshop-trigger:active { transform: scale(0.97); }
30
+ .sb-workshop-trigger svg { width: 16px; height: 16px; fill: currentColor; }
31
+
32
+ /* --- Menu --- */
33
+ .sb-workshop-menu {
34
+ position: absolute;
35
+ bottom: 56px;
36
+ right: 0;
37
+ min-width: 200px;
38
+ background-color: var(--sb-bg, #161b22);
39
+ border: 1px solid var(--sb-border, #30363d);
40
+ border-radius: 12px;
41
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
42
+ overflow: hidden;
43
+ }
44
+
45
+ .sb-workshop-menu-header {
46
+ padding: 10px 16px 6px;
47
+ font-size: 11px;
48
+ font-weight: 600;
49
+ text-transform: uppercase;
50
+ letter-spacing: 0.05em;
51
+ color: var(--sb-fg-muted, #8b949e);
52
+ }
53
+
54
+ .sb-workshop-menu-item {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 8px;
58
+ width: 100%;
59
+ padding: 10px 16px;
60
+ font-size: 13px;
61
+ color: var(--sb-fg, #e6edf3);
62
+ background: none;
63
+ border: none;
64
+ cursor: pointer;
65
+ text-align: left;
66
+ }
67
+ .sb-workshop-menu-item:hover {
68
+ background-color: var(--sb-bg-muted, #21262d);
69
+ }
70
+
71
+ .sb-workshop-menu-icon {
72
+ width: 16px;
73
+ height: 16px;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ font-size: 14px;
78
+ }
79
+
80
+ .sb-workshop-hint {
81
+ padding: 8px 16px;
82
+ font-size: 11px;
83
+ color: var(--sb-fg-muted, #8b949e);
84
+ border-top: 1px solid var(--sb-border-muted, #21262d);
85
+ }
86
+
87
+ /* --- Modal backdrop & panel --- */
88
+ .sb-workshop-backdrop {
89
+ position: fixed;
90
+ top: 0;
91
+ right: 0;
92
+ bottom: 0;
93
+ left: 0;
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ background-color: rgba(0, 0, 0, 0.5);
98
+ z-index: 10000;
99
+ }
100
+
101
+ .sb-workshop-modal {
102
+ width: 100%;
103
+ max-width: 480px;
104
+ background-color: var(--sb-bg, #161b22);
105
+ color: var(--sb-fg, #e6edf3);
106
+ overflow: hidden;
107
+ }
108
+
109
+ .sb-workshop-close-btn {
110
+ font-size: 20px;
111
+ line-height: 1;
112
+ width: 32px;
113
+ height: 32px;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ }
118
+ .sb-workshop-close-btn:hover {
119
+ background-color: var(--sb-bg-muted, #21262d);
120
+ }
121
+
122
+ /* --- Buttons --- */
123
+ .sb-workshop-btn {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ padding: 6px 16px;
127
+ font-size: 13px;
128
+ font-weight: 500;
129
+ border-radius: 6px;
130
+ border: 1px solid transparent;
131
+ cursor: pointer;
132
+ transition: background-color 100ms ease;
133
+ }
134
+ .sb-workshop-btn:disabled {
135
+ opacity: 0.5;
136
+ cursor: not-allowed;
137
+ }
138
+
139
+ .sb-workshop-btn-primary {
140
+ background-color: var(--sb-btn-success, #238636);
141
+ color: #ffffff;
142
+ border-color: rgba(240, 246, 252, 0.1);
143
+ }
144
+ .sb-workshop-btn-primary:hover:not(:disabled) {
145
+ background-color: #2ea043;
146
+ }
147
+
148
+ .sb-workshop-btn-secondary {
149
+ background-color: var(--sb-bg-muted, #21262d);
150
+ color: var(--sb-fg, #e6edf3);
151
+ border-color: var(--sb-border, #30363d);
152
+ }
153
+ .sb-workshop-btn-secondary:hover:not(:disabled) {
154
+ background-color: var(--sb-border-muted, #30363d);
155
+ }
156
+
157
+ /* --- Error state --- */
158
+ .sb-error-bg {
159
+ background-color: rgba(209, 36, 47, 0.1);
160
+ }