feat(paint-slots): paint panel polish — sticky controls, selection outlines, auto-select

- MaterialPaintPanel owns its scroll: the eraser/reset row stays pinned and
  the category tabs stick to the top, so only the material list scrolls.
- Selected catalog swatch + active scene-material card use the same
  `ring-1 ring-primary ring-inset` outline as item/preset tiles.
- Choosing a material category auto-selects its first material.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-18 12:45:45 -04:00
co-authored by Claude Opus 4.8
parent ff2a24795c
commit 8badd415b9
3 changed files with 50 additions and 35 deletions
@@ -56,8 +56,11 @@ export function MaterialPaintPanel() {
}
return (
<div className="w-full space-y-2">
<div className="flex items-center gap-2">
// Fill the host's scroll slot and own the scroll internally: the eraser /
// reset row stays pinned (shrink-0) while only the material list below
// scrolls. The category tabs pin too (sticky, inside the scroll region).
<div className="flex h-full min-h-0 w-full flex-col">
<div className="flex shrink-0 items-center gap-2 pb-2">
<Button
aria-pressed={paintEraser}
className="flex-1"
@@ -79,32 +82,34 @@ export function MaterialPaintPanel() {
Reset all
</Button>
</div>
<MaterialPicker
onChange={(material) => {
// Custom-create: pre-create a scene material and select it as the
// brush via a `scene:` ref so painting stores the ref and edits to
// it propagate everywhere. The user edits it inline in the scene-
// material list below (auto-opened) — no separate right-side pane.
const id = generateSceneMaterialId()
const count = Object.keys(useScene.getState().materials).length
useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material })
setActivePaintMaterial({
materialPreset: toSceneMaterialRef(id),
sourceTarget: activePaintTarget,
})
setAutoEditMaterialId(id)
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
{materialCount > 0 ? (
<PanelSection title="Scene materials">
<SceneMaterialList autoEditId={autoEditMaterialId} />
</PanelSection>
) : null}
<div className="subtle-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto">
<MaterialPicker
onChange={(material) => {
// Custom-create: pre-create a scene material and select it as the
// brush via a `scene:` ref so painting stores the ref and edits to
// it propagate everywhere. The user edits it inline in the scene-
// material list below (auto-opened) — no separate right-side pane.
const id = generateSceneMaterialId()
const count = Object.keys(useScene.getState().materials).length
useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material })
setActivePaintMaterial({
materialPreset: toSceneMaterialRef(id),
sourceTarget: activePaintTarget,
})
setAutoEditMaterialId(id)
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
{materialCount > 0 ? (
<PanelSection title="Scene materials">
<SceneMaterialList autoEditId={autoEditMaterialId} />
</PanelSection>
) : null}
</div>
</div>
)
}
@@ -96,7 +96,7 @@ export function MaterialPicker({
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && (
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap gap-1 pb-1">
<div className="sticky top-0 z-10 flex flex-wrap gap-1 bg-sidebar pb-1">
{availableCategories.map((category) => (
<button
className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${
@@ -107,9 +107,11 @@ export function MaterialPicker({
key={category}
onClick={() => {
setSelectedCategory(category)
if (showCustom) {
setShowCustom(false)
}
// Auto-select the first material in the category so the brush
// is immediately ready (and the row shows as selected).
const first = getMaterialsForCategory(category)[0]
if (first) handleCatalogSelect(first.id)
else if (showCustom) setShowCustom(false)
}}
type="button"
>
@@ -126,7 +128,7 @@ export function MaterialPicker({
return (
<button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
isSelected ? 'bg-sidebar-accent ring-2 ring-primary-foreground' : ''
isSelected ? 'bg-sidebar-accent ring-1 ring-primary ring-inset' : ''
}`}
key={item.id}
onClick={() => {
@@ -159,7 +161,7 @@ export function MaterialPicker({
{selectedCategory === 'colors' && onChange ? (
<button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
showCustom ? 'bg-sidebar-accent ring-2 ring-primary-foreground' : ''
showCustom ? 'bg-sidebar-accent ring-1 ring-primary ring-inset' : ''
}`}
onClick={() => {
triggerSFX('sfx:menu-click')
@@ -32,6 +32,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
const updateSceneMaterial = useScene((state) => state.updateSceneMaterial)
const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const materialEntries = useMemo(
@@ -71,6 +72,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
activePaintTarget={activePaintTarget}
autoEdit={autoEditId === id}
id={id}
isActive={activePaintRef === toSceneMaterialRef(id)}
key={id}
removeSceneMaterial={removeSceneMaterial}
sceneMaterial={sceneMaterial}
@@ -89,6 +91,7 @@ function SceneMaterialRow({
usageCount,
activePaintTarget,
autoEdit,
isActive,
addSceneMaterial,
updateSceneMaterial,
removeSceneMaterial,
@@ -99,6 +102,7 @@ function SceneMaterialRow({
usageCount: number
activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget']
autoEdit: boolean
isActive: boolean
addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
@@ -133,7 +137,11 @@ function SceneMaterialRow({
}
return (
<div className="rounded-md border border-border/60 bg-background/40 p-2">
<div
className={`rounded-md border border-border/60 bg-background/40 p-2 ${
isActive ? 'ring-1 ring-primary ring-inset' : ''
}`}
>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded-md border border-border/70"