ID9 — Unified Gallery Panel Plan (r204 continuation, updated)

**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

---

TL;DR

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.

---

1. Architecture — multi-preview PM2 panel

The 4 categories, 8 slots

| 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).

The panel as a tree

```

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)

```

Why a single panel, not 8 panels

---

2. PreviewStrip — the top bar

Layout

```

┌──────────────────────────────────────────────────────────────┐

│ [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).

Component (FreshCards primitive reuse)

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.

Active state

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`

Disabled state

A chip is disabled (greyed) when:

---

3. Event flow — Gallery → Preview → Desktop

Event bus

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 |

Flow 1: User opens Gallery Panel

```

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>)

```

Flow 2: User clicks a Preview Strip chip

```

<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 })

```

Flow 3: User clicks an asset in another panel (e.g. Visual Card)

```

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

```

Flow 4: User saves a draft in the Gallery

```

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

```

Flow 5: User deploys (state goes to ORIGIN)

```

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

```

---

4. FreshCards components that can be reused directly

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.

What we will NOT reuse from FreshCards

---

5. New bridge components (5 files)

5.1 `GalleryPreviewBridge` (~150 LoC)

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

}

```

5.2 `PreviewHostVSIL` (~80 LoC)

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

}

```

5.3 `PreviewHostOrigin` (~120 LoC)

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):

5.4 `PreviewHostApp` (~100 LoC)

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 />}>`.

5.5 `PreviewHostSurface` (~200 LoC)

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:

5.6 `PreviewStrip` (~100 LoC)

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';

```

Total new LoC: ~750 LoC across 6 files

---

6. Workspace events → preview updates

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.

---

7. Capability manifest for Edge Panel

```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:

---

8. StudioContext adapter

For v1: NO adapter

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.

For v2 (out of scope): a thin adapter

```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.**

---

9. Drift from the canonical FvW v8 / existing doctrine

| 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. |

New pact section needed (recommended)

**`pact/platform/workspace-surfaces/surface-state-enumeration-2026-08-30.md`** (new file):

```md

Surface State Enumeration — 5 states

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.

---

10. The 8-step refactor (3-4 days, 22-28 hours)

| # | 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**.

---

11. The 5-line PM2 contract (post-refactor)

```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.**

---

12. What does NOT change

---

13. 3 open questions for the operator

| # | 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) |

---

14. The 1 thing to remember

> **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.**

---

15. Cross-references

---

**Discovery-only. No code. No pact edits. No extraction. No implementation.**

— vibecoder-standalone-mavis (thread 9)