feat(phase7.1): add HelpTooltip system with contextual help icons

- New HelpTooltip component: hover-triggered floating panel, themed via
  CSS variables, supports top/right/bottom/left positioning, no deps
- New helpTexts.ts registry: 14 entries covering render settings,
  admin actions, template fields, and wizard fields
- Admin.tsx: tooltips on Cycles/EEVEE samples, smooth angle, regenerate
  thumbnails, process unprocessed
- RenderTemplateTable.tsx: tooltips on material replace, lighting only,
  shadow catcher column headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:16:42 +01:00
parent 206672a858
commit 10d05bd2e7
4 changed files with 234 additions and 24 deletions
+95
View File
@@ -0,0 +1,95 @@
import { HelpCircle } from 'lucide-react'
import { useState } from 'react'
import { HELP_TEXTS, type HelpText } from '../help/helpTexts'
interface HelpTooltipProps {
helpKey?: string
help?: HelpText
position?: 'top' | 'right' | 'bottom' | 'left'
size?: number
}
export function HelpTooltip({
helpKey,
help: directHelp,
position = 'right',
size = 14,
}: HelpTooltipProps) {
const [visible, setVisible] = useState(false)
const help = directHelp ?? (helpKey ? HELP_TEXTS[helpKey] : undefined)
if (!help) return null
const tooltipPositionStyle: React.CSSProperties = (() => {
switch (position) {
case 'top':
return { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 6 }
case 'bottom':
return { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: 6 }
case 'left':
return { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: 6 }
case 'right':
default:
return { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: 6 }
}
})()
return (
<span
className="relative inline-flex items-center"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
<HelpCircle
size={size}
className="text-content-muted hover:text-content-secondary transition-colors cursor-help"
/>
{visible && (
<div
className="absolute z-50 rounded-lg border shadow-lg p-3 text-sm"
style={{
...tooltipPositionStyle,
backgroundColor: 'var(--color-bg-surface, var(--color-surface, #fff))',
borderColor: 'var(--color-border-default, #e5e7eb)',
color: 'var(--color-text-content, var(--color-content, #111827))',
maxWidth: 280,
minWidth: 180,
whiteSpace: 'normal',
pointerEvents: 'none',
}}
>
<p className="font-semibold mb-1" style={{ color: 'var(--color-text-content, inherit)' }}>
{help.title}
</p>
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-content-secondary, #6b7280)' }}>
{help.body}
</p>
{help.unit && help.range && (
<p className="text-xs mt-1" style={{ color: 'var(--color-content-muted, #9ca3af)' }}>
Range: {help.range[0]}{help.range[1]} {help.unit}
</p>
)}
{help.recommendation && (
<p
className="text-xs mt-1.5 px-2 py-1 rounded"
style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', color: 'var(--color-status-success-text, #065f46)' }}
>
Recommended: {help.recommendation}
</p>
)}
{help.warning && (
<p
className="text-xs mt-1.5 px-2 py-1 rounded"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', color: 'var(--color-status-warning-text, #92400e)' }}
>
Warning: {help.warning}
</p>
)}
</div>
)}
</span>
)
}
export default HelpTooltip
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
import HelpTooltip from '../HelpTooltip'
import { toast } from 'sonner'
import {
listRenderTemplates,
@@ -165,9 +166,24 @@ export default function RenderTemplateTable() {
<th className="px-3 py-2 font-medium">Category</th>
<th className="px-3 py-2 font-medium">Output Type</th>
<th className="px-3 py-2 font-medium">Collection</th>
<th className="px-3 py-2 font-medium">Mat. Replace</th>
<th className="px-3 py-2 font-medium">Lighting Only</th>
<th className="px-3 py-2 font-medium" title="Enable Shadowcatcher collection (Cycles only)">Shadow Catcher</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Mat. Replace
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Lighting Only
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Shadow Catcher
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</th>
<th className="px-3 py-2 font-medium">.blend File</th>
<th className="px-3 py-2 font-medium">Active</th>
+83
View File
@@ -0,0 +1,83 @@
export interface HelpText {
title: string
body: string
warning?: string
recommendation?: string
unit?: string
range?: [number, number]
}
export const HELP_TEXTS: Record<string, HelpText> = {
// Render settings
'setting.blender_cycles_samples': {
title: 'Cycles Samples',
body: 'Number of render samples per pixel. Higher = better quality, longer render time.',
recommendation: '256 for production, 64 for quick previews',
unit: 'samples',
range: [1, 4096],
},
'setting.blender_eevee_samples': {
title: 'EEVEE Samples',
body: 'Anti-aliasing samples for EEVEE Next renderer. EEVEE is much faster than Cycles but less photorealistic.',
recommendation: '64 for standard quality',
unit: 'samples',
range: [1, 1024],
},
'setting.blender_smooth_angle': {
title: 'Smooth Shading Angle',
body: 'Angle threshold for auto-smooth shading on imported meshes. Below this angle, faces appear smooth; above it, edges show a sharp crease.',
recommendation: '30° works well for most bearings',
unit: 'degrees',
range: [0, 180],
},
'setting.thumbnail_renderer': {
title: 'Thumbnail Renderer',
body: 'Which renderer to use for STEP file thumbnails. Blender produces photorealistic results; Three.js is faster but lower quality.',
recommendation: 'Use Blender for production, Three.js for fast previews',
},
'setting.stl_quality': {
title: 'STL Export Quality',
body: 'Controls tessellation precision when converting STEP to STL for older render paths. High quality = finer mesh, larger file, slower conversion.',
recommendation: 'Low is sufficient for most thumbnails',
},
'action.regenerate_thumbnails': {
title: 'Regenerate All Thumbnails',
body: 'Re-renders thumbnails for all STEP files using current renderer settings. Queues every file on the thumbnail_rendering worker.',
warning: 'This queues a large number of tasks. Only run during off-peak hours.',
},
'action.process_unprocessed': {
title: 'Process Unprocessed',
body: "Queues all STEP files that have status 'pending' or 'failed' for metadata extraction and thumbnail rendering.",
},
'action.generate_missing_stls': {
title: 'Generate Missing STL Caches',
body: "Creates STL cache files for STEP files that don't have them yet. STL caches speed up repeated renders.",
},
'action.seed_aliases': {
title: 'Seed Material Aliases',
body: 'Loads the default Schaeffler material alias mappings (Steel→SCHAEFFLER_010101_Steel-Bare, etc). Safe to run multiple times — existing aliases are not overwritten.',
},
// Template fields
'template.lighting_only': {
title: 'Lighting Only Mode',
body: 'When enabled, this template provides only the world/HDRI lighting environment. The camera position is computed automatically from the product bounding box, ignoring any camera set up in the .blend file.',
recommendation: 'Use for HDR light-setup templates where you want auto-framing',
},
'template.shadow_catcher': {
title: 'Shadow Catcher',
body: "Adds a transparent ground plane that catches shadows from the product. Requires a 'Shadowcatcher' collection in the .blend file. Enables the transparent film option.",
},
'template.material_replace_enabled': {
title: 'Material Replacement',
body: 'When enabled, Blender will replace part materials with the mapped Schaeffler library materials. When disabled, the original .blend materials are used.',
},
// Wizard fields
'wizard.output_type': {
title: 'Output Type',
body: 'Defines what to render: still image, turntable animation, or custom output. Each output type has its own render settings and pricing.',
},
'wizard.render_template': {
title: 'Render Template',
body: 'A .blend file that provides lighting, camera setup, and optionally a background environment. Templates are matched by product category.',
},
}
+37 -21
View File
@@ -5,6 +5,7 @@ import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Setting
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
import HelpTooltip from '../components/HelpTooltip'
import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
@@ -388,7 +389,10 @@ export default function AdminPage() {
{/* Sample counts */}
<div className="grid grid-cols-2 gap-4 max-w-sm">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Cycles samples</label>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
Cycles samples
<HelpTooltip helpKey="setting.blender_cycles_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={4096} step={32}
@@ -400,7 +404,10 @@ export default function AdminPage() {
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">EEVEE samples</label>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
EEVEE samples
<HelpTooltip helpKey="setting.blender_eevee_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={1024} step={16}
@@ -415,7 +422,10 @@ export default function AdminPage() {
{/* Smooth by angle */}
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Smooth angle</span>
<span className="flex items-center gap-1 text-sm font-medium text-content-secondary w-28 shrink-0">
Smooth angle
<HelpTooltip helpKey="setting.blender_smooth_angle" size={12} />
</span>
<input
type="number"
min={0} max={180} step={5}
@@ -674,27 +684,33 @@ export default function AdminPage() {
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<HelpTooltip helpKey="action.process_unprocessed" position="left" />
</div>
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<HelpTooltip helpKey="action.regenerate_thumbnails" position="left" />
</div>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">