ID9 — r205 Structural Plan: VSIL Composition Graph Host

**Operator directive (2026-08-30)**: Begin r205 — implementation-grade structural plan for multi-region VSIL composition. Discovery-only.

**Predecessors**: r203 closed, r204 v2 decision-complete (= ID9 Unified Gallery Panel Plan, a789). ID10 v2 capability layer shipped (b1818, b1269, etc.). This plan **aligns with** a789 (Gallery Panel) and ID10 v2 (FvRE tools, capability manifests).

**VPS location reference**: **a787** at https://artifacts.freshvibeapps.com/vibecoder-standalone/mockups/vibecoder-vps-location-2026-08-30/index.html

---

TL;DR

The **VSIL Composition Graph Host** (VCGH) is a new module that lives at `studio/modules/vibescope/src/composition/` (sibling to `src/vsil/` and `src/inspector/`). It introduces a **new abstraction — the Region** — that groups multiple VSIL sources into a multi-region composition with shared camera, lighting, and atmosphere.

**6 new files, ~1,200 LoC total**:

**Reuses from existing canon** (no fork):

**No new pact files required for v1** (operator's 5 surface states from a789 already cover state lifecycle). Optional: `pact/platform/vsil/composition-2026-08-30.md` (1h, adds the Region contract).

**Constraints honored**:

---

1. The Composition Graph Host (structural v1)

1.1 What is a "region"?

A **region** is a named, bounded subset of a VSIL composition that contains:

A **composition** is a tree of 0..n regions. The composition itself has a shared camera, lighting, atmosphere that all regions render into.

1.2 The Host's responsibilities

The Composition Graph Host manages:

| Responsibility | Description | Integration point |

|----------------|-------------|-------------------|

| **Region discovery** | Find regions in the source | DOM scan (§2) |

| **Region boundaries** | Define where one region ends, another begins | `<div data-vcg-region>` |

| **Region overlays** | Render hover / select / edit / cinematic / error UI | §3 |

| **Region selection** | Track which region(s) the user has selected | `useRegionSelection()` |

| **Region metadata** | Camera, lighting, atmosphere, transforms per region | `composition/types.ts` |

| **Composition graph assembly** | Build the nodes + edges of the composition tree | `composition/graph.ts` |

| **PreviewHostVSIL integration** | Hand the composition to the preview host | a789 `PreviewHostVSIL` |

1.3 The Host as a React component

```ts

export interface CompositionGraphHostProps {

/** The composition source. Either inline or loaded from URL. */

source?: Composition;

/** URL to load composition from. Bridge fetches + sanitizes. */

sourceUrl?: string;

/** Initial selected region IDs. */

initialSelection?: readonly string[];

/** Override the active overlay (used by Edit / Cinematic modes). */

activeOverlay?: OverlayLayer;

/** Host context — provides bridge, store, event bus. */

context: CompositionHostContext;

}

export function CompositionGraphHost(props: CompositionGraphHostProps): React.ReactElement {

// state, effects, render

}

```

1.4 CompositionHostContext (passed in by host)

```ts

export interface CompositionHostContext {

/** The gallery preview bridge (a789). Provides state, localStorage, event bus. */

bridge: GalleryPreviewBridge;

/** The PreviewStrip (a789). Provides active preview slot. */

strip: PreviewStrip;

/** The current StudioContext adapter (ID10 v2, may be null if outside Studio). */

studioContext: StudioContext | null;

/** The current VSILStore (singleton per workspace). */

store: VSILStore;

/** The composition event bus (1-file EventTarget). */

bus: EventTarget;

}

```

The host takes its context as a prop. **No global singletons in v1.** This makes the host testable and hostable from any chrome (PM2, Edge Panel, Gallery).

1.5 What the Host does NOT do (out of scope)

---

2. Region Discovery Model

2.1 DOM scanning rules

The host scans the active preview surface for elements with the `data-vcg-region` attribute. The scanning algorithm:

1. **Top-down tree walk** (depth-first, 1 pass)

2. **Match selector**: `[data-vcg-region]` (any element with this attribute)

3. **For each match**:

- Read the `id` attribute → `regionId` (fallback: `data-vcg-region-id` if present)

- Read the `data-vcg-region-source` attribute → URL of the VSIL source (fallback: `data-vsil-source`)

- Read the `data-vcg-region-bounds` attribute → `[x, y, width, height]` (fallback: getBoundingClientRect)

- Read the `data-vcg-region-meta` attribute → JSON of metadata (camera, lighting, atmosphere, name)

4. **Build a `RegionDescriptor`** for each match

5. **Group regions** by their `data-vcg-region-group` attribute (optional, default: no grouping)

6. **Order regions** by document order (top-down tree walk order)

2.2 VSIL annotation format (HTML attributes)

A region is annotated via attributes on any element:

```html

<div

data-vcg-region

id="hero"

data-vcg-region-id="hero-region"

data-vcg-region-source="/api/vcg/hero.vsil.json"

data-vcg-region-bounds="0,0,1920,1080"

data-vcg-region-meta='{"camera":{"position":[0,0,5],"target":[0,0,0]},"lighting":{"ambient":0.8},"atmosphere":"dawn"}'

data-vcg-region-group="page-1"

>

<!-- The host injects the rendered VSIL here -->

</div>

```

2.3 Region identity model

Each region has a stable, string-typed `RegionId` (e.g. `"hero-region"`, `"footer-region"`). The `RegionId` is:

```ts

export type RegionId = string;

```

2.4 Region ordering

Regions are ordered **by document order** (depth-first tree walk). The host renders regions in the same order they appear in the DOM. The order is stable across renders because the DOM tree is stable.

If two regions have the same `RegionId`, the **last one wins** (DOM dedup). The host logs a warning via `console.warn` but does not throw.

2.5 Region grouping

Regions can be grouped via `data-vcg-region-group` (optional). The group name is a string used for:

```ts

export type RegionGroup = string;

```

The group is **not** a hierarchical concept — it's a flat tag. A region belongs to at most one group. Groups are not nested (no "group inside group" concept in v1).

---

3. Region Overlay System

The host renders 5 overlay layers, all positioned absolutely on top of the regions. Each overlay is a thin React component that subscribes to the host's state.

3.1 The 5 overlay layers

| Overlay | When | What it renders | Source |

|---------|------|-----------------|--------|

| **Hover** | Mouse over a region | 1px outline + region name in top-left corner | `useRegionHover()` |

| **Selection** | Region(s) selected | 2px accent outline + selection handles | `useRegionSelection()` |

| **Active edit** | Region is in WORKING state and being edited | 1px dashed outline + edit-mode indicator | `useRegionEditState()` |

| **Cinematic mode** | Surface state === CINEMATIC | Subtle vignette + film-grain overlay | `useCinematicMode()` |

| **Error boundary** | Region failed to parse / render | Red outline + error message | React `ErrorBoundary` |

3.2 Overlay implementation

Each overlay is a thin React component (~30 LoC each). The host renders all 5 in a single `<CompositionOverlays>` parent (one DOM node, absolute-positioned).

```ts

export function CompositionOverlays(props: { host: CompositionGraphHost }): React.ReactElement {

return (

<div data-vcg-overlays aria-hidden="true">

<HoverOverlay host={props.host} />

<SelectionOverlay host={props.host} />

<ActiveEditOverlay host={props.host} />

<CinematicModeOverlay host={props.host} />

<ErrorBoundaryOverlay host={props.host} />

</div>

);

}

```

3.3 Per-region state isolation

Each region's overlays are scoped to that region. The host maintains a `Map<RegionId, RegionState>` where `RegionState` includes:

```ts

export interface RegionState {

readonly id: RegionId;

readonly descriptor: RegionDescriptor;

readonly parsed: VSILSource | null; // null if parse failed

readonly error: VSILParseError | null; // non-null if parse failed

readonly isHovered: boolean;

readonly isSelected: boolean;

readonly isEditing: boolean; // WORKING + currently being edited

readonly isCinematic: boolean; // in cinematic mode

readonly bounds: DOMRect; // computed once on mount

}

```

The host updates this map on:

---

4. PreviewHostVSIL Integration

The Composition Graph Host is the **data source** for `PreviewHostVSIL`. The integration:

4.1 Data flow

```

CompositionGraphHost (new, this plan)

└─ discovers regions, builds composition

└─ hands Composition (the tree) to PreviewHostVSIL (planned in a789)

└─ PreviewHostVSIL renders each region via the existing renderer

└─ <CinemaProceduralRenderer> or <CinemaUltraRenderer> per region

└─ composition composites all regions into a single canvas

```

4.2 New PreviewHostVSILProps additions

In a789 §5.2, `PreviewHostVSIL` was defined as:

```ts

export interface PreviewHostVSILProps {

source: VSILSource;

selectedLayerIds?: readonly string[];

onLayerClick?: (layerId: string) => void;

}

```

For composition support, we extend (additive, backward-compatible):

```ts

export interface PreviewHostVSILProps {

// existing (unchanged)

source: VSILSource;

selectedLayerIds?: readonly string[];

onLayerClick?: (layerId: string) => void;

// new (additive, optional)

/** Multi-region composition. If present, takes precedence over `source`. */

composition?: Composition;

/** Per-region selection (overrides `selectedLayerIds` for region-aware selection). */

selectedRegionIds?: readonly string[];

/** Per-region click handler. */

onRegionClick?: (regionId: RegionId) => void;

}

```

**Backward compatibility**: existing `PreviewHostVSIL` callsites that pass `source` only continue to work. The new `composition` prop is an alternative path.

4.3 Bridge integration

The `GalleryPreviewBridge` (a789 §5.1) gets 2 new optional props:

```ts

export interface GalleryPreviewBridgeProps {

// existing (unchanged)

initialActivePreview?: PreviewSlot;

surfaceId?: string;

initialAssetId?: string;

// new (additive)

/** Initial composition source (vs `surfaceId` — composition takes precedence). */

initialComposition?: Composition;

initialCompositionUrl?: string;

}

```

When `initialComposition` is set, the bridge loads it via the `composition/graph.ts` builder and hands the resulting `Composition` to `<PreviewHostVSIL>`. The `PreviewStrip` shows a new "Composition" chip in the VSIL group (alongside the existing "VSIL" chip) — see §4.5.

4.4 PreviewStrip addition

The PreviewStrip (a789 §5.6) currently has 8 chips. For composition support, the VSIL chip becomes a submenu:

| Chip (a789) | Submenu items | Action |

|-------------|---------------|--------|

| `vsil-cinematic` | (default) | single VSIL source, no composition |

| `vsil-cinematic` → `composition` | (new) | multi-region composition |

The submenu is a small dropdown attached to the existing VSIL chip. **Total chips: 8 (same as a789) but VSIL chip is a dropdown.**

4.5 Event flow

The host emits 4 new events on the gallery bus:

| Event | Payload | Subscribers |

|-------|---------|-------------|

| `composition:loaded` | `{ composition, regionCount, parseDurationMs }` | PreviewStrip, Edge Panel |

| `composition:region-clicked` | `{ regionId, surfaceState }` | `useRegionSelection`, VibeChat |

| `composition:region-parse-failed` | `{ regionId, error }` | ErrorBoundary overlay, Edge Panel |

| `composition:region-metadata-changed` | `{ regionId, meta }` | Inspector, VibeChat |

---

5. Surface-State Integration

The host behaves differently per the 5 surface states (MAIN / WORKING / DRAFT / PREVIEW / ORIGIN). All 5 states are read-only from the host's perspective; the host observes them and emits events when the user attempts a write.

5.1 Per-state behavior

| State | Host behavior | Selection | Editing | Cinematic |

|-------|---------------|-----------|---------|-----------|

| **MAIN** | read-only (immutable) | allowed | blocked (write rejected, event emitted) | allowed |

| **WORKING** | read-write (in-memory) | allowed | allowed | allowed |

| **DRAFT** | read-write (autosaved) | allowed | allowed | allowed |

| **PREVIEW** | read-only (committed) | allowed | blocked | allowed |

| **ORIGIN** | read-only (immutable, deployed) | allowed | blocked | allowed |

The host reads the current state from `SurfaceRegistry.getState(surfaceId)` (FreshCards, a786) or from the bridge's state event.

5.2 Editing attempts

When the user attempts to edit a region (e.g. drag a layer, change a property) in a read-only state (MAIN, PREVIEW, ORIGIN), the host:

1. **Rejects the edit silently** (no-op, no state change)

2. **Emits** `composition:edit-rejected` with `{ regionId, surfaceState, reason }`

3. **Shows a toast** via the Edge Panel: "Cannot edit a read-only state. Switch to WORKING to edit."

The host does NOT throw or block the UI — it's a soft rejection.

5.3 State transitions

The host itself does NOT trigger state transitions (that's the surface state machine's job, a789 §9). The host **observes** transitions and updates the per-region `RegionState.isEditing` flag accordingly.

When the user clicks "Save Draft" in the host's toolbar (planned in §6), the host emits `composition:save-draft-requested` with `{ composition, regionIds }`. The bridge (or another host) handles the actual save (POST to `/api/compositions/{id}/drafts`).

---

6. VibeChat Integration Points

VibeChat will consume 4 hooks from the Composition Graph Host.

6.1 Region selection events

VibeChat subscribes to `composition:region-clicked` to know which region the user is currently inspecting. The VibeChat prompt can then say:

> "The user has selected the `hero-region`. Their question is about that region. Tailor your answer to the hero region's metadata: {camera, lighting, atmosphere, layerCount}."

The host emits `composition:region-clicked` on every region click (including clicks that change selection).

6.2 Region metadata export

VibeChat calls a synchronous `exportRegionMetadata(regionId: RegionId): RegionMetadataSnapshot` to get a JSON snapshot of the current region's metadata. This is used in the system prompt:

```ts

export interface RegionMetadataSnapshot {

readonly id: RegionId;

readonly name: string;

readonly source: { url?: string; inline?: boolean };

readonly layerCount: number;

readonly assetCount: number;

readonly keyframeTrackCount: number;

readonly camera: CameraMetadata | null;

readonly lighting: LightingMetadata | null;

readonly atmosphere: string | null;

readonly bounds: { x: number; y: number; width: number; height: number };

readonly surfaceState: 'main' | 'working' | 'draft' | 'preview' | 'origin';

readonly parseError: string | null;

}

```

The snapshot is **frozen** (deeply readonly) per the inspector invariant.

6.3 Region transform requests

VibeChat calls `requestRegionTransform(regionId, transform): Promise<RegionTransformResult>` to apply a transform to a region. The host validates the transform (see §7.2) and either:

```ts

export interface RegionTransform {

readonly translate?: readonly [number, number];

readonly scale?: readonly [number, number];

readonly rotate?: number;

readonly opacity?: number;

}

export type RegionTransformResult =

| { readonly ok: true; readonly newBounds: DOMRect }

| { readonly ok: false; readonly reason: 'read-only-state' | 'invalid-transform' | 'region-not-found' | 'busy'; readonly details?: unknown };

```

6.4 Theme / preset application

VibeChat calls `applyPreset(regionId, presetId): Promise<PresetResult>` to apply a theme preset (e.g. "dawn", "midnight", "studio") to a region. The host:

1. Looks up the preset in the composition's `presets` map

2. Applies the preset's metadata (camera, lighting, atmosphere)

3. If the surface is read-only, returns `{ ok: false, reason: 'read-only-state' }`

```ts

export interface RegionPreset {

readonly id: string;

readonly name: string;

readonly camera?: CameraMetadata;

readonly lighting?: LightingMetadata;

readonly atmosphere?: string;

}

export type PresetResult =

| { readonly ok: true; readonly appliedAt: number }

| { readonly ok: false; readonly reason: 'read-only-state' | 'preset-not-found' | 'region-not-found'; readonly details?: unknown };

```

6.5 VibeChat-facing API surface

All 4 hooks live on the host as React Context methods (so any descendant can call them):

```ts

export interface CompositionHostContext {

// existing

bridge: GalleryPreviewBridge;

strip: PreviewStrip;

studioContext: StudioContext | null;

store: VSILStore;

bus: EventTarget;

// new

exportRegionMetadata: (regionId: RegionId) => RegionMetadataSnapshot | null;

requestRegionTransform: (regionId: RegionId, transform: RegionTransform) => Promise<RegionTransformResult>;

applyPreset: (regionId: RegionId, presetId: string) => Promise<PresetResult>;

getComposition: () => Composition;

}

```

---

7. Security Model

7.1 DOM sanitization rules

When the host injects region content into the DOM, it follows these rules:

1. **No `innerHTML`** — use React's `createElement` or `dangerouslySetInnerHTML` ONLY with sanitized content

2. **No script execution** — strip `<script>` tags from any user-provided HTML

3. **No event handler injection** — strip `on*` attributes (onclick, onload, etc.)

4. **No `javascript:` URLs** — reject `href` / `src` values starting with `javascript:`

5. **No data: URLs** except `data:image/*` (the renderer needs image data)

6. **CSP-compatible** — the host sets `Content-Security-Policy: default-src 'self'; img-src 'self' data:; media-src 'self';` on the host's root element

The host uses **DOMPurify** (or an equivalent) for any HTML sanitization. 1 new dep: `dompurify@^3.0.0`.

7.2 Allowed VSIL attributes

The host validates the parsed VSIL source against a strict allowlist:

| Attribute | Allowed values | Notes |

|-----------|----------------|-------|

| `layer.kind` | `rect`, `ellipse`, `path`, `text`, `image`, `group`, `mask`, `stroke`, `polygon`, `gradient`, `filter`, `component` | matches `scrubber/types.ts` |

| `blend_mode` | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten` | matches existing types |

| `lighting.kind` | `ambient`, `directional`, `point` | matches `sceneGraph.ts` |

| `asset.kind` | `image`, `font`, `audio`, `video`, `model`, `shader` | matches existing types |

Any value outside the allowlist → `VSILParseError` (existing).

7.3 Allowed transforms

The host validates `RegionTransform` requests:

| Field | Allowed | Range | Notes |

|-------|---------|-------|-------|

| `translate` | `[number, number]` | `[-10000, 10000]` | pixels, no infinity / NaN |

| `scale` | `[number, number]` | `[0.01, 100]` | non-zero, no infinity / NaN |

| `rotate` | `number` | `[-360, 360]` | degrees |

| `opacity` | `number` | `[0, 1]` | inclusive |

Any value outside the allowed range → `{ ok: false, reason: 'invalid-transform', details: {...} }`.

7.4 Error-boundary behaviour

The host wraps the entire composition render in a React `ErrorBoundary`. If a region fails to parse or render:

1. **The error is caught** (no crash to the panel)

2. **The region shows a red outline** + error message

3. **The host emits** `composition:region-parse-failed` with `{ regionId, error }`

4. **The host continues rendering** the other regions normally

5. **The user can retry** by clicking the error region's outline (re-fetches + re-parses)

```ts

export function CompositionErrorBoundary(props: { children: React.ReactNode; onError: (error: Error) => void }): React.ReactElement {

// standard React error boundary

}

```

7.5 Read-only invariant

**The host never mutates VSIL sources directly.** All mutations go through the surface state machine (which decides whether the surface is read-write). This is the same invariant as the Inspector (a788) — the host is a **viewer**, not an editor.

The only writes the host does are:

---

8. Drift identified

| Pact / source | Drift | Severity |

|---------------|-------|----------|

| `pact/platform/vsil/vsil.md` | **No "Region" concept** — VSIL is per-source, not per-region. Need a new section or a sibling file. | medium |

| `pact/platform/vsil/vsil-a.md` | Composition is "VSIL ∪ VSIL-A" per existing pact. **No multi-region concept.** | medium |

| `pact/platform/workspace-surfaces/surface-state-enumeration-2026-08-30.md` (a789 §9) | Doesn't enumerate which surface states allow region editing. | low |

| `pact/governance/constitution/workspace-surface-model-2026-07-14.md` | 5 visibility states (visible/curtained/hidden-active/unused/dormant) — orthogonal to the new region concept. No conflict. | none |

| `pact/platform/inspector/inspector.md` | No change. Inspector is still per-source. The composition is just a tree of VSIL sources. | none |

| ID9 Inspector (a788) | No change. Reuses the 688 LoC inspector proper. | none |

| ID9 Gallery Panel (a789) | 2 new optional props on `PreviewHostVSIL` and `GalleryPreviewBridge` (additive). 1 new submenu on the VSIL chip in `PreviewStrip` (additive). | low |

| ID10 v2 capability layer | The new `VCGH_CAPABILITIES` follows the same pattern (a788, a789). | none |

Optional new pact file (1h, recommended)

**`pact/platform/vsil/composition-2026-08-30.md`** — defines the Region concept as an extension of VSIL. **Not blocking** for v1; can be added as a follow-up.

---

9. The 6-step implementation plan (NOT this turn — design phase only)

For operator awareness. **This is NOT to be done in this discovery-only turn.**

| # | Step | Time | What | Risk |

|---|------|------|------|------|

| 1 | `composition/types.ts` | 2h | Region, Composition, RegionMetadata types | none |

| 2 | `composition/discovery.ts` | 4h | DOM scan + annotation parser | low |

| 3 | `composition/graph.ts` | 6h | Composition graph builder (nodes + edges) | medium |

| 4 | `composition/host.tsx` | 8h | `<CompositionGraphHost>` React component | medium |

| 5 | `composition/overlays/` | 4h | 5 overlay layers | low |

| 6 | `composition/capabilities.ts` + tests | 4h | Capability manifest + smoke tests | low |

**Total: ~28h** = 3-4 days of focused work. **This is a structural plan, not an implementation directive.** The operator chooses when to start r205 implementation.

---

10. The 1 thing to remember

> **The Composition Graph Host introduces a Region abstraction that lets multiple VSIL sources coexist in one composition. The host is READ-ONLY (like the Inspector) — it discovers regions, builds the graph, and hands it to `PreviewHostVSIL`. Mutations go through the surface state machine, not the host. 6 new files, ~1,200 LoC, no new pact required for v1, 3-4 day implementation, all 5 surface states honored.**

---

11. Cross-references

---

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

— vibecoder-standalone-mavis (thread 9)