@bitovi/vybit 0.9.0 → 0.10.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.
@@ -16,7 +16,7 @@
16
16
  font-size: 12px;
17
17
  }
18
18
  </style>
19
- <script type="module" crossorigin src="/panel/assets/index-CzGpZV08.js"></script>
19
+ <script type="module" crossorigin src="/panel/assets/index-CRaV-Cwr.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/panel/assets/index-BdUOBofL.css">
21
21
  </head>
22
22
  <body>
package/server/app.ts CHANGED
@@ -9,6 +9,7 @@ import { createProxyMiddleware } from "http-proxy-middleware";
9
9
  import { getByStatus, getQueueUpdate, clearAll } from "./queue.js";
10
10
  import { resolveTailwindConfig, generateCssForClasses, getTailwindVersion } from "./tailwind.js";
11
11
  import { loadStoryArgTypes } from "./storybook.js";
12
+ import { loadCache, getAllCachedGhosts, setCachedGhost, invalidateAll as invalidateGhostCache } from "./ghost-cache.js";
12
13
  import type { PatchStatus } from "../shared/types.js";
13
14
 
14
15
  const VALID_STATUSES = new Set<string>(['staged', 'committed', 'implementing', 'implemented', 'error']);
@@ -17,6 +18,9 @@ export function createApp(packageRoot: string, storybookUrl: string | null = nul
17
18
  const app = express();
18
19
  app.use(cors());
19
20
 
21
+ // Load ghost cache from disk on startup
22
+ loadCache();
23
+
20
24
  app.get("/overlay.js", (_req, res) => {
21
25
  const overlayPath = path.join(packageRoot, "overlay", "dist", "overlay.js");
22
26
  res.sendFile(overlayPath, (err) => {
@@ -84,6 +88,26 @@ export function createApp(packageRoot: string, storybookUrl: string | null = nul
84
88
  res.json({ ok: true });
85
89
  });
86
90
 
91
+ // --- Ghost cache REST endpoints ---
92
+ app.get('/api/ghost-cache', (_req, res) => {
93
+ res.json(getAllCachedGhosts());
94
+ });
95
+
96
+ app.post('/api/ghost-cache', express.json({ limit: '1mb' }), (req, res) => {
97
+ const { storyId, args, ghostHtml, hostStyles, storyBackground, componentName, componentPath } = req.body;
98
+ if (!storyId || typeof ghostHtml !== 'string') {
99
+ res.status(400).json({ error: 'storyId and ghostHtml are required' });
100
+ return;
101
+ }
102
+ setCachedGhost({ storyId, args, ghostHtml, hostStyles: hostStyles ?? {}, storyBackground, componentName: componentName ?? '', componentPath });
103
+ res.json({ ok: true });
104
+ });
105
+
106
+ app.delete('/api/ghost-cache', (_req, res) => {
107
+ invalidateGhostCache();
108
+ res.json({ ok: true });
109
+ });
110
+
87
111
  // --- Storybook proxy ---
88
112
  if (storybookUrl) {
89
113
  // Vite's dev server serves assets at many root-absolute path prefixes:
@@ -0,0 +1,154 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+
5
+ export interface GhostCacheEntry {
6
+ storyId: string;
7
+ argsHash: string;
8
+ ghostHtml: string;
9
+ hostStyles: Record<string, string>;
10
+ storyBackground?: string;
11
+ componentName: string;
12
+ componentPath?: string;
13
+ extractedAt: number;
14
+ }
15
+
16
+ interface CacheFile {
17
+ entries: GhostCacheEntry[];
18
+ }
19
+
20
+ const MAX_ENTRIES = 200;
21
+ const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5 MB
22
+
23
+ const cache = new Map<string, GhostCacheEntry>();
24
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
25
+ let cacheDir: string | null = null;
26
+ let cacheFilePath: string | null = null;
27
+
28
+ function getCacheDir(): string {
29
+ if (!cacheDir) {
30
+ cacheDir = path.join(process.cwd(), "node_modules", ".cache", "vybit");
31
+ }
32
+ return cacheDir;
33
+ }
34
+
35
+ function getCacheFilePath(): string {
36
+ if (!cacheFilePath) {
37
+ cacheFilePath = path.join(getCacheDir(), "ghost-cache.json");
38
+ }
39
+ return cacheFilePath;
40
+ }
41
+
42
+ export function computeArgsHash(args?: Record<string, unknown>): string {
43
+ if (!args || Object.keys(args).length === 0) return "";
44
+ const sorted = JSON.stringify(args, Object.keys(args).sort());
45
+ return crypto.createHash("sha1").update(sorted).digest("hex").slice(0, 12);
46
+ }
47
+
48
+ function cacheKey(storyId: string, argsHash: string): string {
49
+ return argsHash ? `${storyId}::${argsHash}` : storyId;
50
+ }
51
+
52
+ /** Load the cache from disk into memory. Safe to call multiple times. */
53
+ export function loadCache(): void {
54
+ cache.clear();
55
+ const filePath = getCacheFilePath();
56
+ try {
57
+ const raw = fs.readFileSync(filePath, "utf-8");
58
+ const data: CacheFile = JSON.parse(raw);
59
+ if (Array.isArray(data.entries)) {
60
+ for (const entry of data.entries) {
61
+ cache.set(cacheKey(entry.storyId, entry.argsHash), entry);
62
+ }
63
+ }
64
+ } catch {
65
+ // File doesn't exist or is corrupt — start fresh
66
+ }
67
+ }
68
+
69
+ /** Write the cache to disk. Creates the directory if needed. */
70
+ function flushToDisk(): void {
71
+ const filePath = getCacheFilePath();
72
+ const dir = getCacheDir();
73
+ try {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ const data: CacheFile = { entries: Array.from(cache.values()) };
76
+ fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
77
+ } catch (err) {
78
+ console.error("[ghost-cache] Failed to write cache:", err);
79
+ }
80
+ }
81
+
82
+ function scheduleFlush(): void {
83
+ if (flushTimer) clearTimeout(flushTimer);
84
+ flushTimer = setTimeout(() => {
85
+ flushTimer = null;
86
+ flushToDisk();
87
+ }, 500);
88
+ }
89
+
90
+ /** Evict oldest entries until we're under the size and count limits. */
91
+ function evictIfNeeded(): void {
92
+ // Evict by count
93
+ while (cache.size > MAX_ENTRIES) {
94
+ let oldestKey: string | null = null;
95
+ let oldestTime = Infinity;
96
+ for (const [key, entry] of cache) {
97
+ if (entry.extractedAt < oldestTime) {
98
+ oldestTime = entry.extractedAt;
99
+ oldestKey = key;
100
+ }
101
+ }
102
+ if (oldestKey) cache.delete(oldestKey);
103
+ else break;
104
+ }
105
+
106
+ // Evict by total size
107
+ let totalBytes = () => {
108
+ let sum = 0;
109
+ for (const entry of cache.values()) sum += entry.ghostHtml.length;
110
+ return sum;
111
+ };
112
+ while (totalBytes() > MAX_TOTAL_BYTES && cache.size > 0) {
113
+ let oldestKey: string | null = null;
114
+ let oldestTime = Infinity;
115
+ for (const [key, entry] of cache) {
116
+ if (entry.extractedAt < oldestTime) {
117
+ oldestTime = entry.extractedAt;
118
+ oldestKey = key;
119
+ }
120
+ }
121
+ if (oldestKey) cache.delete(oldestKey);
122
+ else break;
123
+ }
124
+ }
125
+
126
+ export function getCachedGhost(storyId: string, argsHash: string): GhostCacheEntry | null {
127
+ return cache.get(cacheKey(storyId, argsHash)) ?? null;
128
+ }
129
+
130
+ export function setCachedGhost(entry: Omit<GhostCacheEntry, "argsHash" | "extractedAt"> & { args?: Record<string, unknown> }): void {
131
+ const argsHash = computeArgsHash(entry.args);
132
+ const full: GhostCacheEntry = {
133
+ storyId: entry.storyId,
134
+ argsHash,
135
+ ghostHtml: entry.ghostHtml,
136
+ hostStyles: entry.hostStyles,
137
+ storyBackground: entry.storyBackground,
138
+ componentName: entry.componentName,
139
+ componentPath: entry.componentPath,
140
+ extractedAt: Date.now(),
141
+ };
142
+ cache.set(cacheKey(full.storyId, argsHash), full);
143
+ evictIfNeeded();
144
+ scheduleFlush();
145
+ }
146
+
147
+ export function getAllCachedGhosts(): GhostCacheEntry[] {
148
+ return Array.from(cache.values());
149
+ }
150
+
151
+ export function invalidateAll(): void {
152
+ cache.clear();
153
+ scheduleFlush();
154
+ }
@@ -119,6 +119,9 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
119
119
  console.error(`[ws] Design patch staged: ${patch.id}`);
120
120
  } else if (msg.type === "DESIGN_CLOSE") {
121
121
  broadcastTo("overlay", { type: "DESIGN_CLOSE" }, ws);
122
+ } else if (msg.type === "RESET_SELECTION") {
123
+ broadcastTo("panel", { type: "RESET_SELECTION" }, ws);
124
+ console.error(`[ws] Reset selection broadcast to panels`);
122
125
  } else if (msg.type === "COMPONENT_DROPPED") {
123
126
  const patch = addPatch({ ...msg.patch, kind: msg.patch.kind ?? 'component-drop' });
124
127
  console.error(`[ws] Component-drop patch staged: #${patch.id}`);
package/shared/types.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  // Shared types for the PATCH protocol.
2
2
  // Imported by overlay (esbuild), panel (Vite), and server (tsx).
3
3
 
4
+ /** Cached ghost HTML + host styles for instant component preview placeholders. */
5
+ export interface GhostCacheEntry {
6
+ storyId: string;
7
+ argsHash: string;
8
+ ghostHtml: string;
9
+ hostStyles: Record<string, string>;
10
+ storyBackground?: string;
11
+ componentName: string;
12
+ componentPath?: string;
13
+ extractedAt: number;
14
+ }
15
+
4
16
  export type ContainerName = 'modal' | 'popover' | 'sidebar' | 'popup';
5
17
 
6
18
  /** A component placed on the Fabric.js design canvas */
@@ -311,6 +323,11 @@ export interface ClosePanelMessage {
311
323
  type: 'CLOSE_PANEL';
312
324
  }
313
325
 
326
+ /** Overlay → Server: story changed in Storybook, clear panel selection */
327
+ export interface ResetSelectionMessage {
328
+ type: 'RESET_SELECTION';
329
+ }
330
+
314
331
  // ---------------------------------------------------------------------------
315
332
  // Component arm-and-place messages
316
333
  // ---------------------------------------------------------------------------
@@ -361,7 +378,7 @@ export type PanelToOverlay =
361
378
  | ClosePanelMessage
362
379
  | ComponentArmMessage
363
380
  | ComponentDisarmMessage;
364
- export type OverlayToServer = PatchStagedMessage | ComponentDroppedMessage;
381
+ export type OverlayToServer = PatchStagedMessage | ComponentDroppedMessage | ResetSelectionMessage;
365
382
  export type PanelToServer = PatchCommitMessage | MessageStageMessage;
366
383
  export type ClientToServer =
367
384
  | RegisterMessage
@@ -371,10 +388,12 @@ export type ClientToServer =
371
388
  | DesignSubmitMessage
372
389
  | DesignCloseMessage
373
390
  | ComponentDroppedMessage
391
+ | ResetSelectionMessage
374
392
  | PingMessage;
375
393
  export type ServerToClient =
376
394
  | PongMessage
377
395
  | QueueUpdateMessage
396
+ | ResetSelectionMessage
378
397
  | PatchUpdateMessage
379
398
  | PatchImplementingMessage
380
399
  | PatchImplementedMessage
@@ -405,5 +424,6 @@ export type AnyMessage =
405
424
  | ComponentDisarmMessage
406
425
  | ComponentDisarmedMessage
407
426
  | ComponentDroppedMessage
427
+ | ResetSelectionMessage
408
428
  | PingMessage
409
429
  | PongMessage;
@@ -0,0 +1 @@
1
+ module.exports = require('./preset');
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { addons, types } from '@storybook/manager-api';
3
+ import { AddonPanel } from '@storybook/components';
4
+
5
+ const ADDON_ID = 'vybit';
6
+ const PANEL_ID = `${ADDON_ID}/panel`;
7
+
8
+ addons.register(ADDON_ID, (api) => {
9
+ addons.add(PANEL_ID, {
10
+ type: types.PANEL,
11
+ title: 'Vybit',
12
+ paramKey: 'vybit',
13
+ render: ({ active }) => {
14
+ const serverUrl =
15
+ api.getCurrentParameter<{ serverUrl?: string }>('vybit')?.serverUrl
16
+ ?? 'http://localhost:3333';
17
+
18
+ if (active) {
19
+ api.togglePanelPosition('right');
20
+ }
21
+
22
+ return (
23
+ <AddonPanel active={active ?? false}>
24
+ <iframe
25
+ src={`${serverUrl}/panel/`}
26
+ style={{ width: '100%', height: '100%', border: 'none' }}
27
+ title="Vybit Panel"
28
+ />
29
+ </AddonPanel>
30
+ );
31
+ },
32
+ });
33
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@bitovi/vybit-storybook-addon",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "commonjs",
6
+ "description": "Storybook addon wiring for Vybit — overlay injection + panel registration",
7
+ "peerDependencies": {
8
+ "storybook": "^8.0.0"
9
+ }
10
+ }
@@ -0,0 +1,7 @@
1
+ const { join } = require('path');
2
+
3
+ module.exports = {
4
+ managerEntries(entry = []) {
5
+ return [...entry, join(__dirname, './manager.tsx')];
6
+ },
7
+ };
@@ -0,0 +1,29 @@
1
+ import { addons } from '@storybook/preview-api';
2
+
3
+ let injected = false;
4
+
5
+ export const decorators = [
6
+ (StoryFn: any, context: any) => {
7
+ const serverUrl =
8
+ context.parameters?.vybit?.serverUrl ?? 'http://localhost:3333';
9
+
10
+ if (!injected) {
11
+ const script = document.createElement('script');
12
+ script.src = `${serverUrl}/overlay.js`;
13
+ document.head.appendChild(script);
14
+ injected = true;
15
+ }
16
+
17
+ return StoryFn();
18
+ },
19
+ ];
20
+
21
+ const channel = addons.getChannel();
22
+ let lastStoryId: string | undefined;
23
+
24
+ channel.on('storyRendered', (storyId?: string) => {
25
+ // Only reset selection on actual story navigation, not HMR updates
26
+ if (storyId && storyId === lastStoryId) return;
27
+ lastStoryId = storyId;
28
+ window.postMessage({ type: 'STORYBOOK_STORY_RENDERED' }, '*');
29
+ });