parametric-inspector: render def.presentation.icon before the title

User pointed out the auto-derived panel was title-only; the legacy
panels rendered a small icon in front. Legacy used a URL path (next
Image). The registry-driven path has structured `IconRef` values
(iconify / svg / lazy component) declared on `def.presentation.icon`.

- PanelWrapper's `icon` prop now accepts `string | React.ReactNode`.
  String → next/image (legacy URL behavior). Node → rendered as-is.
- ParametricInspector resolves `def.presentation.icon` to a node:
  iconify → `<Icon icon="lucide:fence" />`, svg → inline svg, component
  → Suspense + lazy.

Kind-owned custom panels (slab/ceiling) keep their existing legacy
URL icons since they pass `<PanelWrapper>` themselves — no change
needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-18 10:35:18 -04:00
co-authored by Claude Opus 4.7
parent 282cb22585
commit 8c2b03f99b
2 changed files with 42 additions and 5 deletions
@@ -7,7 +7,11 @@ import { cn } from '../../../lib/utils'
interface PanelWrapperProps {
title: string
icon?: string
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
* rendered via next/image) OR a React node (registry-driven
* inspector renders `<Icon icon="lucide:fence" />` from
* `def.presentation.icon`). */
icon?: string | React.ReactNode
onClose?: () => void
onReset?: () => void
onBack?: () => void
@@ -58,9 +62,18 @@ export function PanelWrapper({
<ChevronLeft className="h-4 w-4" />
</button>
)}
{icon && (
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
)}
{icon &&
(typeof icon === 'string' ? (
<Image
alt=""
className="shrink-0 object-contain"
height={16}
src={icon}
width={16}
/>
) : (
<span className="flex shrink-0 items-center justify-center">{icon}</span>
))}
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
{title}
</h2>
@@ -3,11 +3,13 @@
import {
type AnyNode,
type AnyNodeId,
type IconRef,
nodeRegistry,
type ParamField,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Icon } from '@iconify/react'
import { Move, Trash2 } from 'lucide-react'
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -94,11 +96,12 @@ export function ParametricInspector() {
const presentation = def.presentation
const title = presentation?.label ?? nodeType ?? ''
const iconNode = renderIcon(presentation?.icon)
const canMove = !!def.capabilities.movable
const canDelete = def.capabilities.deletable !== false
return (
<PanelWrapper onClose={handleClose} title={title} width={320}>
<PanelWrapper icon={iconNode} onClose={handleClose} title={title} width={320}>
{parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => (
@@ -132,6 +135,27 @@ export function ParametricInspector() {
)
}
function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined {
if (!ref) return undefined
if (ref.kind === 'iconify') {
return <Icon height={16} icon={ref.name} width={16} />
}
if (ref.kind === 'svg') {
return (
<svg height={16} viewBox={ref.viewBox} width={16}>
<path d={ref.path} fill="currentColor" />
</svg>
)
}
// `component`: lazy-loaded custom icon component. Suspense-safe.
const LazyIcon = lazy(ref.module)
return (
<Suspense fallback={null}>
<LazyIcon />
</Suspense>
)
}
// Cache lazy custom panel components by their loader so React.lazy isn't
// re-invoked across renders.
const customPanelCache = new WeakMap<() => Promise<unknown>, ComponentType>()