diff --git a/apps/editor/app/viewer/[id]/collections-panel.tsx b/apps/editor/app/viewer/[id]/collections-panel.tsx
new file mode 100644
index 00000000..149e1414
--- /dev/null
+++ b/apps/editor/app/viewer/[id]/collections-panel.tsx
@@ -0,0 +1,300 @@
+'use client'
+
+import {
+ type AnyNodeId,
+ type CollectionId,
+ type Control,
+ type ControlValue,
+ type ItemNode,
+ useInteractive,
+ useScene,
+} from '@pascal-app/core'
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import { useState } from 'react'
+import { useShallow } from 'zustand/react/shallow'
+import { cn } from '@/lib/utils'
+
+// ─── Shared control derivation ───────────────────────────────────────────────
+
+type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
+
+type SharedControlDef = {
+ kind: 'toggle' | 'slider' | 'temperature'
+ label?: string
+ min?: number
+ max?: number
+ step?: number
+ unit?: string
+ refs: ItemControlRef[]
+}
+
+function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
+ if (items.length === 0) return []
+ const result: SharedControlDef[] = []
+
+ for (const kind of ['toggle', 'slider', 'temperature'] as const) {
+ const refs: ItemControlRef[] = []
+ let ref: Control | null = null
+ let allHave = true
+
+ for (const item of items) {
+ const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
+ if (idx === -1) { allHave = false; break }
+ refs.push({ itemId: item.id, controlIndex: idx })
+ if (!ref) ref = item.asset.interactive!.controls[idx]!
+ }
+
+ if (!allHave || !ref) continue
+
+ const def: SharedControlDef = { kind, label: ref.label, refs }
+ if ('min' in ref) { def.min = ref.min; def.max = ref.max }
+ if ('step' in ref) def.step = (ref as { step?: number }).step
+ if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
+ result.push(def)
+ }
+
+ return result
+}
+
+// ─── Shared control widget ───────────────────────────────────────────────────
+
+function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
+ if (def.kind === 'toggle') {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {def.label ?? def.kind}
+ {value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}
+
+
onChange(Number(e.target.value))}
+ onPointerDown={(e) => e.stopPropagation()}
+ className="w-full accent-white"
+ />
+
+ )
+}
+
+// ─── Individual item control widget ──────────────────────────────────────────
+
+function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
+ if (control.kind === 'toggle') {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {control.label ?? control.kind}
+ {value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}
+
+
onChange(Number(e.target.value))}
+ onPointerDown={(e) => e.stopPropagation()}
+ className="w-full accent-white"
+ />
+
+ )
+}
+
+// ─── Collection row ───────────────────────────────────────────────────────────
+
+function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
+ const collection = useScene((s) => s.collections[collectionId])
+
+ const interactiveItems = useScene(
+ useShallow((s) =>
+ (collection?.nodeIds ?? [])
+ .map((id) => s.nodes[id])
+ .filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
+ ),
+ )
+
+ const controlValuesByItem = useInteractive(
+ useShallow((s) =>
+ Object.fromEntries(interactiveItems.map((n) => [n.id, s.items[n.id]?.controlValues ?? []])),
+ ),
+ )
+
+ const setControlValue = useInteractive((s) => s.setControlValue)
+
+ const [expanded, setExpanded] = useState(false)
+ const [expandedItemIds, setExpandedItemIds] = useState>(new Set())
+
+ if (!collection) return null
+
+ const sharedControls = deriveSharedControls(interactiveItems)
+
+ const getSharedValue = (def: SharedControlDef): ControlValue => {
+ if (def.kind === 'toggle') {
+ return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
+ }
+ const first = def.refs[0]!
+ return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
+ }
+
+ const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
+ for (const { itemId, controlIndex } of def.refs) {
+ setControlValue(itemId, controlIndex, value)
+ }
+ }
+
+ const toggleItemExpand = (id: AnyNodeId) => {
+ setExpandedItemIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Expanded */}
+ {expanded && (
+
+ {interactiveItems.length === 0 ? (
+
No interactive items.
+ ) : (
+ <>
+ {/* Shared controls */}
+ {sharedControls.length > 0 && (
+
+
All
+
+ {sharedControls.map((def, i) => (
+ setSharedValue(def, v)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Individual items */}
+ {interactiveItems.map((item) => {
+ const isItemExpanded = expandedItemIds.has(item.id)
+ const controls = item.asset.interactive!.controls
+ const values = controlValuesByItem[item.id] ?? []
+
+ return (
+
+
+
+ {isItemExpanded && (
+
+ {controls.map((control, i) => (
+ setControlValue(item.id, i, v)}
+ />
+ ))}
+
+ )}
+
+ )
+ })}
+ >
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Main panel ───────────────────────────────────────────────────────────────
+
+export function CollectionsPanel() {
+ const collectionIds = useScene(
+ useShallow((s) => Object.keys(s.collections) as CollectionId[]),
+ )
+
+ if (collectionIds.length === 0) return null
+
+ return (
+
+
+ Collections
+
+
+ {collectionIds.map((id) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
index b2c06bb1..03ff3721 100644
--- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx
+++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
@@ -26,6 +26,7 @@ import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ActionButton } from '@/components/ui/action-menu/action-button'
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
import { emitter } from '@pascal-app/core'
+import { CollectionsPanel } from './collections-panel'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
@@ -246,6 +247,11 @@ export const ViewerOverlay = ({
)}
+ {/* Collections Panel - Top Right */}
+
+
+
+
{/* Controls Panel - Bottom Center */}
diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx
index 8a72a8d5..d3465ec4 100644
--- a/apps/editor/components/ui/item-catalog/catalog-items.tsx
+++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx
@@ -1260,6 +1260,24 @@ export const CATALOG_ITEMS: AssetInput[] = [
offset: [0.04, 0, 0.02],
rotation: [0, 0, 0],
dimensions: [1, 1.9, 1],
+ interactive: {
+ controls: [
+ {
+ kind: 'toggle',
+ },
+ {
+ kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial'
+ }
+ ],
+ effects: [
+ {
+ kind: 'light',
+ intensityRange: [0, 2],
+ color: '#ffffff',
+ offset: [0, 1.4, 0],
+ }
+ ]
+ }
},
{