**Operator directive (2026-08-30)**: The Unified Gallery Panel must support ALL preview types visible in the FreshVibe header: VSIL, Origin, App, and Surface previews (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN). This replaces the earlier VSIL-only preview assumption.
**Discovery-only.** No implementation. No extraction. No pact edits.
**VPS location reference** (per operator feedback 2026-08-30): **a787** at https://artifacts.freshvibeapps.com/vibecoder-standalone/mockups/vibecoder-vps-location-2026-08-30/index.html
---
The **Unified Gallery Panel** is one PM2 panel that hosts **8 preview slots** in 4 categories, switchable via a top **Preview Strip**:
1. **VSIL Preview** (cinematic canvas) — reuses VibeScope's `VisualCard.tsx` (already exists)
2. **Origin Preview** (live site DOM) — reuses the ID10 `origin.js` template (already exists, 6 sections)
3. **App Preview** (app shell viewport) — reuses `VibeCoder` main SPA rendered in a `<div data-app-preview>`
4. **Surface Previews** — 5 sub-modes (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN) that show different renderings of the current surface
**No iframe. No postMessage.** Native PM2 panel. Reuses FreshCards primitives where possible.
**5 new bridge components**: `GalleryPreviewBridge`, `PreviewHostVSIL`, `PreviewHostOrigin`, `PreviewHostApp`, `PreviewHostSurface`, `PreviewStrip`. Total new code: ~600-800 LoC across 6 files.
**3 new concepts to coin** (not in FvW v8 today): the **Preview Slot** (a panel that hosts a PreviewHost), the **Preview Strip** (a chip-row in the panel header), the **Surface Preview States** (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN — these are operator-defined, not yet in pact).
**No StudioContext adapter needed for v1.** The panel takes `surfaceId` + `source` as props. The host (PM2 / Edge Panel) provides them.
---
| Category | Slot | What it shows | Source | Status |
|----------|------|---------------|--------|--------|
| **VSIL** | `vsil-cinematic` | The VSIL document rendered as canvas | `useVSILSource()` from VibeScope's VSILStore | ✅ already exists (VisualCard) |
| **Origin** | `origin-live` | The deployed live site (e.g. `https://example.com`) | URL prop, fetched on mount | ⚠️ NEW: uses iframe is FORBIDDEN, so we use `fetch + DOM inject` (server-rendered HTML, sanitized) |
| **App** | `app-shell` | The current VibeCoder app shell rendered as a viewport | Same React tree, mounted with reduced props | ✅ can reuse VibeCoder's `main-BFflfQnG.js` mount |
| **Surface** | `surface-main` | The MAIN state of the current surface (the canonical, last-shipped version) | Fetched from `surface-registry://{surfaceId}@state=main` | 🆕 NEW: `PreviewHostSurface` |
| **Surface** | `surface-working` | The WORKING state (the in-memory edits, not yet saved) | In-memory `useWorkingContext` | 🆕 NEW |
| **Surface** | `surface-draft` | The DRAFT state (autosaved, persisted but not committed) | `localStorage` or PB `drafts/` collection | 🆕 NEW |
| **Surface** | `surface-preview` | The PREVIEW state (committed, ready to ship) | `surface-registry://{surfaceId}@state=preview` | 🆕 NEW |
| **Surface** | `surface-origin` | The ORIGIN state (deployed live, immutable) | Fetched from deployed URL | 🆕 NEW |
The 5 surface states (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN) are operator-coined in this directive. **They are NOT yet in the FvW v8 pact.** They need a constitutional home — recommended location: `pact/platform/workspace-surfaces/surface-state-enumeration-2026-08-30.md` (see §10).
```
PM2 (PanelManager)
└─<UnifiedGalleryPanel> (one panel instance, docked right or centre)
├─<PreviewStrip> (top bar, 8 chips)
│ └─ active = 'vsil-cinematic' (or whichever)
└─<PreviewBody> (one host at a time)
└─<PreviewHostVSIL> | <PreviewHostOrigin> | <PreviewHostApp> | <PreviewHostSurface>
└─<PreviewContent> (the actual rendered preview)
```
---
```
┌──────────────────────────────────────────────────────────────┐
│ [VSIL] [Origin] [App] | [Main] [Working] [Draft] [Preview] [Origin] │ ← PreviewStrip
├──────────────────────────────────────────────────────────────┤
│ │
│ <PreviewBody> — the active preview rendered here │
│ │
└──────────────────────────────────────────────────────────────┘
```
The `|` separates the 3 "category" previews (VSIL / Origin / App) from the 5 "surface state" previews (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN).
Uses FreshCards' `CardChipSystem` from `studio/modules/freshcards/src/Card.tsx` (already exists, public API exported). 8 chips, color-coded:
| Chip | Color | Source |
|------|-------|--------|
| VSIL | `#5b8cff` (vibescope blue) | hardcoded — visual identity |
| Origin | `#4de1c1` (origin teal) | hardcoded |
| App | `#a78bfa` (app purple) | hardcoded |
| Main | `#9ba3af` (gray — readonly) | hardcoded |
| Working | `#fbbf24` (amber — unsaved) | hardcoded |
| Draft | `#38bdf8` (cyan — autosaved) | hardcoded |
| Preview | `#a78bfa` (purple — committed) | hardcoded |
| Origin | `#4de1c1` (teal — deployed) | hardcoded |
The 2 "Origin" chips are visually identical — that's the point. The semantics differ (one is "the Origin category preview", one is "the ORIGIN state of the current surface") but the visual treatment is the same.
Clicking a chip:
1. Updates the active preview (`gallery:active-preview-changed` event)
2. The `<PreviewBody>` swaps to the corresponding `PreviewHost*`
3. Persists to localStorage as `vibecoder-gallery-active-preview`
4. The Edge Panel can introspect `GalleryPreviewBridge.activePreview`
A chip is disabled (greyed) when:
---
Reuse the same `EventTarget` bus from the ID9 Inspector plan (`studio/modules/vibescope/src/inspector/eventBus.ts`). New event names with a `gallery:` prefix:
| Event | Payload | Emitter | Subscribers |
|-------|---------|---------|-------------|
| `gallery:active-preview-changed` | `{ from, to, source }` | `<PreviewStrip>` | `<PreviewBody>`, Edge Panel, capability manifest |
| `gallery:surface-state-changed` | `{ surfaceId, fromState, toState, content }` | `<PreviewHostSurface>` | PreviewStrip (re-evaluate disabled), Edge Panel |
| `gallery:asset-bound` | `{ assetId, surfaceId, vsilSource? }` | workspace | `<PreviewHostVSIL>`, `<PreviewHostSurface>`, Edge Panel |
| `gallery:preview-loaded` | `{ slot, durationMs, bytes }` | `<PreviewHost*>` | Edge Panel (analytics), capability manifest |
| `gallery:preview-failed` | `{ slot, error, retryUrl? }` | `<PreviewHost*>` | Edge Panel, error toast |
```
PM2.addPanel({id: 'gallery', content: <UnifiedGalleryPanel />})
└─<UnifiedGalleryPanel> mounts
└─ Reads localStorage for last-active preview
└─ Renders <PreviewStrip> with active chip
└─ Renders <PreviewBody> with the matching <PreviewHost*>
└─ Dispatches `gallery:preview-loaded` (slot=<active>)
```
```
<PreviewStrip onClick(chipId)>
└─ dispatchEvent('gallery:active-preview-changed', { from, to })
└─ <UnifiedGalleryPanel> re-renders <PreviewBody>
└─ Old <PreviewHost*> unmounts
└─ New <PreviewHost*> mounts
└─ <PreviewHost*> reads its source (useVSILSource, fetch URL, useWorkingContext, etc.)
└─ Renders <PreviewContent>
└─ dispatchEvent('gallery:preview-loaded', { slot: chipId, durationMs, bytes })
```
```
Visual Card (in VibeScope workspace)
└─ user clicks a layer
└─ VSILStore.setState({ selectedLayerIds: [layerId] })
└─ dispatchEvent('gallery:asset-bound', { assetId: layerId, surfaceId, vsilSource })
└─ <PreviewHostVSIL> re-renders with new selection
└─ <PreviewHostSurface> (if active) re-evaluates the surface state
└─ Edge Panel updates its context tiles
```
```
User clicks "Save Draft" in <PreviewHostSurface state="working">
└─ onClick → POST /api/surfaces/{surfaceId}/drafts
└─ 201 Created → dispatchEvent('gallery:surface-state-changed', { fromState: 'working', toState: 'draft', content })
└─ <PreviewStrip> updates Working chip → Draft chip (Working becomes stale)
└─ Edge Panel adds "View Draft" tile
```
```
User clicks "Deploy" in <PreviewHostSurface state="preview">
└─ onClick → POST /api/surfaces/{surfaceId}/deploy
└─ 201 Created → fetch deployed URL
└─ dispatchEvent('gallery:surface-state-changed', { fromState: 'preview', toState: 'origin', deployedUrl })
└─ <PreviewStrip> re-evaluates all 5 surface state chips
└─ Edge Panel adds "Open live site" tile
```
---
From the ID9-FreshCards audit (a786), the following FreshCards components are **directly reusable** in the Unified Gallery Panel:
| FreshCards component | Path | Reuse in Gallery Panel |
|----------------------|------|------------------------|
| `<Card>` | `studio/modules/freshcards/src/Card.tsx` | Card primitive for the panel chrome |
| `<InnerCard>` | `studio/modules/freshcards/src/InnerCard.tsx` | Sub-card for nested previews (e.g. thumbnail within thumbnail) |
| `useInnerCardStack` | `studio/modules/freshcards/src/InnerCardStack.tsx` | Enforce MAX 3 inner cards (DC-VC-3) |
| `<GalleryView>` | `studio/modules/freshcards/src/views/GalleryView.tsx` | The thumbnail grid of all available previews (when active-preview is "all") |
| `<KanbanView>` | `studio/modules/freshcards/src/views/KanbanView.tsx` | Alternative view: surface states as columns (Main / Working / Draft / Preview / Origin) |
| `<ListView>` | `studio/modules/freshcards/src/views/ListView.tsx` | Compact list of preview slots |
| `<CalendarView>` | `studio/modules/freshcards/src/views/CalendarView.tsx` | NOT relevant — no time dimension here |
| `<DatabaseViewSwitcher>` | `studio/modules/freshcards/src/views/DatabaseViewSwitcher.tsx` | Switch between Gallery / Kanban / List views |
| `SurfaceRegistry` | `studio/modules/freshcards/src/registry/SurfaceRegistry.ts` | The source-of-truth for surface state queries |
| `SurfaceLifecycleManager` | `studio/modules/freshcards/src/registry/SurfaceLifecycleManager.ts` | Handles state transitions (MAIN → WORKING → DRAFT → PREVIEW → ORIGIN) |
| `<CardChipSystem>` | `studio/modules/freshcards/src/Card.tsx` (re-exported) | The PreviewStrip |
| `FreshCardsPrompts` | `studio/modules/freshcards/src/ai/FreshCardsPrompts.ts` | AI prompts for "what should I do with this preview?" (Phase F integration) |
**No fork needed.** These are already a clean importable surface.
---
The panel root. Owns:
```ts
export interface GalleryPreviewBridgeProps {
/** Initial active preview slot. Default: from localStorage or 'vsil-cinematic'. */
initialActivePreview?: PreviewSlot;
/** Surface to preview. Default: active surface from StudioContext. */
surfaceId?: string;
/** Initial asset binding (e.g. selected VSIL layer). */
initialAssetId?: string;
}
export function GalleryPreviewBridge(props: GalleryPreviewBridgeProps): React.ReactElement {
// state, effects, capability manifest
}
```
Renders the VSIL cinematic preview. Reuses `VisualCard.tsx` from VibeScope.
```ts
export interface PreviewHostVSILProps {
source: VSILSource; // the live VSIL document
selectedLayerIds?: readonly string[];
onLayerClick?: (layerId: string) => void;
}
export function PreviewHostVSIL(props: PreviewHostVSILProps): React.ReactElement {
// wraps VisualCard with PreviewContent chrome
}
```
Renders the deployed live site. **NO iframe** (forbidden by directive). Uses `fetch()` + DOM injection.
```ts
export interface PreviewHostOriginProps {
url: string; // e.g. https://example.com
/** Sanitize: strip <script> before injecting. */
sanitize?: boolean; // default true
/** Strip CMS tags (operator mode). */
stripCmsTags?: boolean; // default true
/** Inject dev-mode links (operator-only). */
injectDevLinks?: boolean; // default false
}
export function PreviewHostOrigin(props: PreviewHostOriginProps): React.ReactElement {
// fetch HTML, parse, sanitize, inject into <div data-origin-preview>
}
```
The fetch is done via the bridge server (no CORS issues):
Renders the current VibeCoder app shell as a viewport. Reuses the SPA mount directly (no fork).
```ts
export interface PreviewHostAppProps {
/** App route to preview. Default: '/' */
route?: string;
/** Device frame (e.g. 'mobile-iphone-14', 'tablet-ipad', 'desktop'). */
device?: 'mobile-iphone-14' | 'mobile-pixel-7' | 'tablet-ipad' | 'desktop' | 'desktop-hd';
/** Scale factor. Default: 1. */
scale?: number;
}
export function PreviewHostApp(props: PreviewHostAppProps): React.ReactElement {
// Renders <iframe src={route} data-app-preview> wrapped in a device frame
// Wait — the directive says NO IFRAME. Use direct DOM mount instead.
}
```
**WAIT** — directive says no iframe. The app preview cannot be iframe-isolated (forbidden). Two options:
**Default: option (a) with error boundary.** The `PreviewHostApp` re-mounts the VibeCoder app with reduced props inside a `<ErrorBoundary fallback={<AppPreviewError />}>`.
The most complex host. Renders the current surface in 1 of 5 states.
```ts
export interface PreviewHostSurfaceProps {
surfaceId: string;
state: 'main' | 'working' | 'draft' | 'preview' | 'origin';
/** When state='origin', the URL to fetch. */
originUrl?: string;
}
export function PreviewHostSurface(props: PreviewHostSurfaceProps): React.ReactElement {
// fetches content from surface-registry, applies state-specific rendering
}
```
State-specific behavior:
The top bar with 8 chips. Pure presentation.
```ts
export interface PreviewStripProps {
active: PreviewSlot;
disabled?: Partial<Record<PreviewSlot, boolean>>;
onChange: (slot: PreviewSlot) => void;
/** Show the 5 surface state chips or hide them. */
showSurfaceStates?: boolean;
}
export type PreviewSlot =
| 'vsil-cinematic'
| 'origin-live'
| 'app-shell'
| 'surface-main'
| 'surface-working'
| 'surface-draft'
| 'surface-preview'
| 'surface-origin';
```
---
The 4 workspace events the panel subscribes to:
| Event | Source | Effect on panel |
|-------|--------|-----------------|
| `studio:workspace-changed` | StudioContext | Re-render `<PreviewBody>` with new surface (e.g. switched from VibeCoder → VibeScope) |
| `studio:asset-bound` | workspace (VibeScope, VibeCoder) | Re-render `<PreviewHostVSIL>` with new selection; re-evaluate `<PreviewHostSurface>` |
| `studio:surface-state-changed` | surface editor | Update surface state chips in `<PreviewStrip>`; re-fetch `<PreviewHostSurface>` |
| `studio:deploy-completed` | deploy pipeline | Update `<PreviewStrip>` ORIGIN chip; refresh `<PreviewHostOrigin>` if active |
These are **additive** to the workspace's existing event bus. The panel doesn't change how workspaces emit events.
---
```ts
export const GALLERY_PREVIEW_CAPABILITIES = Object.freeze({
can: Object.freeze({
previewVSIL: true,
previewOrigin: true,
previewApp: true,
previewSurface: true,
switchSlots: true,
persistActiveSlot: true,
}),
needs: Object.freeze({
surfaceId: 'required | optional | none',
assetId: 'optional',
originUrl: 'optional',
}),
contextRules: Object.freeze({
showInWorkspace: ['vibecoder', 'vibescope', 'freshcards', 'origin'],
showInContext: ['surface-editing', 'asset-selected', 'preview-requested'],
showInMode: ['normal', 'half', 'full'],
}),
listens: [
'studio:workspace-changed',
'studio:asset-bound',
'studio:surface-state-changed',
'studio:deploy-completed',
'gallery:active-preview-changed',
],
emits: [
'gallery:preview-loaded',
'gallery:preview-failed',
'gallery:surface-state-changed',
],
availableSlots: Object.freeze([
'vsil-cinematic',
'origin-live',
'app-shell',
'surface-main',
'surface-working',
'surface-draft',
'surface-preview',
'surface-origin',
] as const),
});
```
The Edge Panel reads this and shows tiles like:
---
The `<UnifiedGalleryPanel>` takes `surfaceId` and `assetId` as props. The host (PM2 / Edge Panel) is responsible for providing them. This is the "panel as pure component" pattern.
```ts
// hypothetical: studio/modules/gallery/src/adapters/useStudioContextSurface.ts
export function useStudioContextSurface(): { surfaceId: string; assetId?: string } {
const { activeWorkspace, activeAssetId } = useStudioContext();
return {
surfaceId: activeWorkspace,
assetId: activeAssetId,
};
}
```
This 20-line hook lets the panel be **drop-in** (no props required) when inside a Studio that has `StudioContext` set up. **Additive change, NOT in v1 scope.**
---
| Pact section | Status | Drift |
|--------------|--------|-------|
| `pact/platform/workspace-surfaces/workspace-surfaces.md` | partial | 5 surface states (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN) are NOT in the existing pact. Need new section. |
| `pact/platform/vsil/vsil.md` | no drift | VSIL cinematic preview is canonical (VisualCard) |
| `pact/platform/cards/cards.md` | no drift | FreshCards primitives are reusable as-is |
| `pact/governance/constitution/workspace-surface-model-2026-07-14.md` | partial | The 6 clarifications cover 5 *visibility* states (visible / curtained / hidden-active / unused / dormant), not 5 *content* states (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN). Different concept. |
| `pact/blueprint/panels/backstage-panel.md` | no drift | Panel chrome pattern is reusable |
| ID10 Preview Engine (`src/host/preview-engine.js`) | partial | 5 modes (shadow / execution / template / device / breakpoint) are *rendering modes*, not *content states*. Different concept, but the chrome integration pattern is reusable. |
**`pact/platform/workspace-surfaces/surface-state-enumeration-2026-08-30.md`** (new file):
```md
Per operator directive 2026-08-30 (r204):
- MAIN : the canonical, last-shipped version (immutable, read-only)
- WORKING : the in-memory edits, not yet saved (volatile, lost on reload)
- DRAFT : the autosaved, persisted but not committed (localStorage or PB)
- PREVIEW : the committed, ready to ship (mutable, can be re-edited)
- ORIGIN : the deployed live, immutable (mirror of the production state)
State machine:
MAIN → WORKING (user starts editing)
WORKING → DRAFT (autosave timer fires, or user clicks Save Draft)
DRAFT → PREVIEW (user clicks Commit)
PREVIEW → ORIGIN (deploy pipeline runs)
ORIGIN → MAIN (origin becomes the new main on next deploy)
DRAFT → WORKING (user clicks Discard)
PREVIEW → DRAFT (user clicks Revert to Draft)
This is a DIFFERENT enumeration from the 5 visibility states
(visible / curtained / hidden-active / unused / dormant) in
workspace-surface-model-2026-07-14.md. Visibility = "where the
surface is mounted". State = "what content the surface has".
```
This is a 5-minute add. Not in this plan's implementation, but recommended for the operator to approve.
---
| # | Step | Time | What | Risk |
|---|------|------|------|------|
| 1 | New pact section (optional) | 1h | `surface-state-enumeration-2026-08-30.md` | none |
| 2 | Define `PreviewSlot` type | 1h | New `studio/modules/gallery/src/types.ts` with 8 slot IDs + 5 surface state IDs | none |
| 3 | Build `GalleryPreviewBridge` | 4h | State management + localStorage + event subscription | low |
| 4 | Build `PreviewHostVSIL` | 2h | Wraps VisualCard with PreviewContent chrome | low |
| 5 | Build `PreviewHostOrigin` | 4h | `fetch` + DOM inject + sanitize + bridge endpoint | medium (security) |
| 6 | Build `PreviewHostApp` | 3h | Error boundary + re-mount + device frame | medium |
| 7 | Build `PreviewHostSurface` | 6h | 5 states × fetch logic + SurfaceRegistry integration | medium |
| 8 | Build `PreviewStrip` + capability manifest | 2h | Chip row + Edge Panel introspection | low |
| 9 | Tests + smoke | 3-4h | Unit tests per host + Playwright smoke (open panel, switch 8 slots, take screenshot of each) | low |
**Total: 26-27h** — at the upper end of "2-3 days, refactor outline" the operator asked for. Add a buffer of 4-6h for security review (Origin fetch + sanitize) → 30-33h = **3-4 days of focused work**.
---
```ts
import { UnifiedGalleryPanel } from 'studio/modules/gallery/src/UnifiedGalleryPanel';
import { GALLERY_PREVIEW_CAPABILITIES } from 'studio/modules/gallery/src/capabilities';
if (GALLERY_PREVIEW_CAPABILITIES.can.previewSurface) {
mgr.addPanel({
id: 'unified-gallery',
title: 'Gallery',
content: <UnifiedGalleryPanel initialActivePreview="vsil-cinematic" />,
icon: 'gallery',
on: { edge: 'right' },
});
}
```
**No iframe. No postMessage. Just a React component.**
---
---
| # | Question | Default if no answer |
|---|----------|---------------------|
| Q1 | **Vendor**: copy new files to `avidtech6/vibecoder-standalone/src/` (standalone worktree) or to `avidtech6/freshvibestudio/studio/modules/gallery/` (shared with the Studio)? | `freshvibestudio/studio/modules/gallery/` (VibeCoder follows Studio) |
| Q2 | **Mount**: register panel automatically at PM2 boot (always-on) or only when context fires (`studio:asset-bound`)? | both: register as always-available + auto-open on `studio:asset-bound` |
| Q3 | **Adapter scope**: ship the StudioContext adapter in v1 (so the panel works drop-in inside Studio) or defer to v2? | defer to v2 (panel takes props in v1, adapter is 20 lines for v2) |
---
> **8 preview slots in 4 categories, 1 panel, 0 iframes.** The PreviewStrip is 8 chips, the PreviewBody swaps between 4 host types (VSIL/Origin/App/Surface×5), and the GalleryPreviewBridge owns state + persistence + capability manifest. **Reuse FreshCards Card + 4 views + SurfaceRegistry. Add 6 new files (~750 LoC). Mount in 5 lines.**
---
---
**Discovery-only. No code. No pact edits. No extraction. No implementation.**
— vibecoder-standalone-mavis (thread 9)