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 ( return (
<div className="w-full space-y-2"> // Fill the host's scroll slot and own the scroll internally: the eraser /
<div className="flex items-center gap-2"> // 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 <Button
aria-pressed={paintEraser} aria-pressed={paintEraser}
className="flex-1" className="flex-1"
@@ -79,32 +82,34 @@ export function MaterialPaintPanel() {
Reset all Reset all
</Button> </Button>
</div> </div>
<MaterialPicker <div className="subtle-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto">
onChange={(material) => { <MaterialPicker
// Custom-create: pre-create a scene material and select it as the onChange={(material) => {
// brush via a `scene:` ref so painting stores the ref and edits to // Custom-create: pre-create a scene material and select it as the
// it propagate everywhere. The user edits it inline in the scene- // brush via a `scene:` ref so painting stores the ref and edits to
// material list below (auto-opened) — no separate right-side pane. // it propagate everywhere. The user edits it inline in the scene-
const id = generateSceneMaterialId() // material list below (auto-opened) — no separate right-side pane.
const count = Object.keys(useScene.getState().materials).length const id = generateSceneMaterialId()
useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material }) const count = Object.keys(useScene.getState().materials).length
setActivePaintMaterial({ useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material })
materialPreset: toSceneMaterialRef(id), setActivePaintMaterial({
sourceTarget: activePaintTarget, materialPreset: toSceneMaterialRef(id),
}) sourceTarget: activePaintTarget,
setAutoEditMaterialId(id) })
}} setAutoEditMaterialId(id)
onSelectMaterialPreset={(materialPreset) => { }}
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) onSelectMaterialPreset={(materialPreset) => {
}} setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
selectedMaterialPreset={activePaintMaterial?.materialPreset} }}
value={activePaintMaterial?.material} selectedMaterialPreset={activePaintMaterial?.materialPreset}
/> value={activePaintMaterial?.material}
{materialCount > 0 ? ( />
<PanelSection title="Scene materials"> {materialCount > 0 ? (
<SceneMaterialList autoEditId={autoEditMaterialId} /> <PanelSection title="Scene materials">
</PanelSection> <SceneMaterialList autoEditId={autoEditMaterialId} />
) : null} </PanelSection>
) : null}
</div>
</div> </div>
) )
} }
@@ -96,7 +96,7 @@ export function MaterialPicker({
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}> <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && ( {(catalogItems.length > 0 || onChange) && (
<div className="min-w-0 space-y-1"> <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) => ( {availableCategories.map((category) => (
<button <button
className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${ className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${
@@ -107,9 +107,11 @@ export function MaterialPicker({
key={category} key={category}
onClick={() => { onClick={() => {
setSelectedCategory(category) setSelectedCategory(category)
if (showCustom) { // Auto-select the first material in the category so the brush
setShowCustom(false) // 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" type="button"
> >
@@ -126,7 +128,7 @@ export function MaterialPicker({
return ( return (
<button <button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${ 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} key={item.id}
onClick={() => { onClick={() => {
@@ -159,7 +161,7 @@ export function MaterialPicker({
{selectedCategory === 'colors' && onChange ? ( {selectedCategory === 'colors' && onChange ? (
<button <button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${ 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={() => { onClick={() => {
triggerSFX('sfx:menu-click') triggerSFX('sfx:menu-click')
@@ -32,6 +32,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
const updateSceneMaterial = useScene((state) => state.updateSceneMaterial) const updateSceneMaterial = useScene((state) => state.updateSceneMaterial)
const removeSceneMaterial = useScene((state) => state.removeSceneMaterial) const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget) const activePaintTarget = useEditor((state) => state.activePaintTarget)
const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const materialEntries = useMemo( const materialEntries = useMemo(
@@ -71,6 +72,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
activePaintTarget={activePaintTarget} activePaintTarget={activePaintTarget}
autoEdit={autoEditId === id} autoEdit={autoEditId === id}
id={id} id={id}
isActive={activePaintRef === toSceneMaterialRef(id)}
key={id} key={id}
removeSceneMaterial={removeSceneMaterial} removeSceneMaterial={removeSceneMaterial}
sceneMaterial={sceneMaterial} sceneMaterial={sceneMaterial}
@@ -89,6 +91,7 @@ function SceneMaterialRow({
usageCount, usageCount,
activePaintTarget, activePaintTarget,
autoEdit, autoEdit,
isActive,
addSceneMaterial, addSceneMaterial,
updateSceneMaterial, updateSceneMaterial,
removeSceneMaterial, removeSceneMaterial,
@@ -99,6 +102,7 @@ function SceneMaterialRow({
usageCount: number usageCount: number
activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget'] activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget']
autoEdit: boolean autoEdit: boolean
isActive: boolean
addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial'] addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial'] updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial'] removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
@@ -133,7 +137,11 @@ function SceneMaterialRow({
} }
return ( 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"> <div className="flex items-center gap-2">
<span <span
className="h-8 w-8 shrink-0 rounded-md border border-border/70" className="h-8 w-8 shrink-0 rounded-md border border-border/70"