From ceebd8eaed08aecab531099fc38494ea3eb40d3c Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 15:52:21 -0700 Subject: [PATCH 01/20] Play animation clips in the 3D viewer Rigged models (from the rigging extensions, or any GLB with clips) render frozen in bind pose today: the viewer loads the glTF but never creates an AnimationMixer, so the clips it just parsed never run. That reads as a failed rig rather than a missing feature. Create a mixer for models that carry clips, tick it from useFrame, and dispose it with the model. When a file carries more than one clip, offer a small picker so each motion can be reviewed without leaving the app. Co-Authored-By: Claude Fable 5 --- src/areas/generate/components/Viewer3D.tsx | 46 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 8cff822f..9318c212 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -2,7 +2,7 @@ import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState import type { ReactNode, ErrorInfo, MutableRefObject } from 'react' import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber' -import { Environment, GizmoHelper, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' +import { Environment, GizmoHelper, Html, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' import { EffectComposer, Outline, Select, Selection } from '@react-three/postprocessing' import * as THREE from 'three' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' @@ -151,8 +151,48 @@ function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject } function GltfMeshModel(props: MeshModelProps): JSX.Element { - const { scene } = useGLTF(props.url) - return + const gltf = useGLTF(props.url) + const { scene, animations } = gltf + const mixerRef = useRef(null) + const [clipIndex, setClipIndex] = useState(0) + + // Rigged models arrive with one or more clips; without a mixer they render + // frozen in bind pose, which reads as "the rig failed". + useEffect(() => { + if (!animations?.length) { + mixerRef.current = null + return + } + const mixer = new THREE.AnimationMixer(scene) + mixer.clipAction(animations[Math.min(clipIndex, animations.length - 1)]).play() + mixerRef.current = mixer + return () => { + mixer.stopAllAction() + mixer.uncacheRoot(scene) + mixerRef.current = null + } + }, [scene, animations, clipIndex]) + + useFrame((_state, delta) => mixerRef.current?.update(delta)) + + return ( + <> + + {animations && animations.length > 1 && ( + + + + )} + + ) } function ObjMeshModel(props: MeshModelProps): JSX.Element { From 91127308b313c49b157db29c2ee630ff4786f5e5 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 16:37:41 -0700 Subject: [PATCH 02/20] Add Library thumbnail previews and a resizable panel Asset rows now render their pre-rendered .thumb.png beside the filename instead of showing bare text, falling back to the existing text-only row when no thumbnail exists. The Library panel can also be resized by dragging its edge, with the chosen width persisted across restarts. --- .../main/artifact-registry-service.test.ts | 27 ++++++ electron/main/artifact-registry-service.ts | 25 ++++++ .../preload/artifact-registry-preload.test.ts | 2 + electron/preload/electron-api.ts | 3 + src/areas/generate/GeneratePage.tsx | 82 +++++++++++++++++-- .../generate/assetLibraryService.test.ts | 19 +++++ src/areas/generate/assetLibraryService.ts | 9 ++ src/areas/generate/assetLibraryUi.test.ts | 12 +++ src/areas/generate/assetLibraryUi.ts | 29 +++++++ src/shared/types/assetLibrary.ts | 10 +++ src/shared/types/electron.d.ts | 3 + 11 files changed, 215 insertions(+), 6 deletions(-) diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 882120d4..1e01df29 100644 --- a/electron/main/artifact-registry-service.test.ts +++ b/electron/main/artifact-registry-service.test.ts @@ -10,6 +10,7 @@ import { normalizeWorkspaceAssetPath, openWorkspaceAssetLibraryEntry, readWorkspaceAssetLibraryEntry, + readWorkspaceAssetLibraryThumbnail, registerWorkspaceAssetLibraryIpcHandlers, } from './artifact-registry-service.ts' @@ -198,6 +199,27 @@ test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath assert.equal(!mismatched.success && mismatched.error.code, 'not-openable') })) +test('reads a sibling .thumb.png for GLB assets and stays silent when one is missing', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/no-thumb.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply') + + const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(withThumb.success, true) + assert.equal(withThumb.success && withThumb.dataUrl, `data:image/png;base64,${Buffer.from('fake-png-bytes').toString('base64')}`) + + const withoutThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/no-thumb.glb' }) + assert.equal(withoutThumb.success, false) + + const nonMesh = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/static.ply' }) + assert.equal(nonMesh.success, false) + + const unsafe = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: '../secret.glb' }) + assert.equal(unsafe.success, false) +})) + test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => { const handlers = new Map Promise>() registerWorkspaceAssetLibraryIpcHandlers({ @@ -224,7 +246,12 @@ test('registers workspace library IPC handlers with structured results', async ( assert.equal(typeof handlers.get('workspace:library:list'), 'function') assert.equal(typeof handlers.get('workspace:library:read'), 'function') assert.equal(typeof handlers.get('workspace:library:open'), 'function') + assert.equal(typeof handlers.get('workspace:library:thumbnail'), 'function') const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' }) assert.equal(typeof (result as { success?: unknown }).success, 'boolean') assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path') + const thumbnail = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: '../escape.glb' }) + assert.equal((thumbnail as { success: boolean }).success, false) + const missingPayload = await handlers.get('workspace:library:thumbnail')?.({}, {}) + assert.equal((missingPayload as { success: boolean }).success, false) }) diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts index 3c26de73..469bf23e 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -12,6 +12,7 @@ import type { AssetLibraryPreviewPayload, AssetLibraryReadResult, AssetLibrarySourceScope, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' import type { ArtifactProvenance } from '../../src/shared/types/artifacts' @@ -51,6 +52,10 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR sourceWorkspacePath?: string } +export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string +} + interface AssetLibraryMetadata { sourceWorkspacePath?: string manifestWorkspacePath?: string @@ -342,6 +347,21 @@ export async function openWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibr return { success: true, entry: read.entry } } +// Thumbnails are pre-rendered beside their source mesh (see thumbs.py): a +// `.glb` asset may have a sibling `.thumb.png`. Missing files are +// a normal, silent condition, never surfaced as an AssetLibraryError. +export async function readWorkspaceAssetLibraryThumbnail(request: WorkspaceAssetLibraryThumbnailRequest): Promise { + try { + const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath) + if (!isGlbOrGltf(normalized.workspacePath)) return { success: false } + const thumbnailAbsolutePath = normalized.absolutePath.replace(/\.(glb|gltf)$/i, '.thumb.png') + const buffer = await readFile(thumbnailAbsolutePath) + return { success: true, dataUrl: `data:image/png;base64,${buffer.toString('base64')}` } + } catch { + return { success: false } + } +} + function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string } { if (typeof payload !== 'object' || payload === null) return {} const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown } @@ -363,4 +383,9 @@ export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLib if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) }) + deps.ipcMain.handle('workspace:library:thumbnail', async (_event, payload) => { + const { workspacePath } = readPayloadRequest(payload) + if (!workspacePath) return { success: false } + return readWorkspaceAssetLibraryThumbnail({ workspaceDir: deps.getWorkspaceDir(), workspacePath }) + }) } diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 511589e4..fd85a9a6 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -24,6 +24,7 @@ test('preload exposes scoped workspace library list/read/open methods', async () workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }) + await api.workspace.library.thumbnail({ workspacePath: 'Exports/hero.glb' }) assert.deepEqual(calls, [ { channel: 'workspace:library:list', payload: undefined }, @@ -41,5 +42,6 @@ test('preload exposes scoped workspace library list/read/open methods', async () sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }, }, + { channel: 'workspace:library:thumbnail', payload: { workspacePath: 'Exports/hero.glb' } }, ]) }) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 4f351b49..daccf2ba 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,6 +4,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' export interface IpcRendererLike { @@ -189,6 +191,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra list: (): Promise => ipcRenderer.invoke('workspace:library:list') as Promise, read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, + thumbnail: (request: AssetLibraryThumbnailRequest): Promise => ipcRenderer.invoke('workspace:library:thumbnail', request) as Promise, }, }, diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index a6dbe4f3..e258ecb4 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -12,12 +12,15 @@ import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from ' import { ASSET_LIBRARY_SORT_OPTIONS, buildAssetLibraryOpenRequest, + clampAssetLibraryPanelWidth, createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, type GenerateOpenPanel, @@ -368,6 +371,8 @@ function AssetLibraryPopover({ searchQuery, sortMode, collapsedSectionKeys, + thumbnails, + panelWidth, onSelectEntry, onSearchQueryChange, onSortModeChange, @@ -375,6 +380,7 @@ function AssetLibraryPopover({ onOpenSelected, onRefresh, onClose, + onResizeMouseDown, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -384,6 +390,9 @@ function AssetLibraryPopover({ searchQuery: string sortMode: AssetLibrarySortMode collapsedSectionKeys: string[] + /** Data URLs keyed by workspacePath, populated lazily as thumbnails load. */ + thumbnails: Record + panelWidth: number onSelectEntry: (entryId: string) => void onSearchQueryChange: (value: string) => void onSortModeChange: (value: AssetLibrarySortMode) => void @@ -391,6 +400,7 @@ function AssetLibraryPopover({ onOpenSelected: () => void onRefresh: () => void onClose: () => void + onResizeMouseDown: (event: React.MouseEvent) => void }) { const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) @@ -409,7 +419,8 @@ function AssetLibraryPopover({
@@ -425,6 +436,15 @@ function AssetLibraryPopover({
+ {/* Resize handle — drag to widen/narrow the library panel; size is persisted. */} +
+ ) })} @@ -575,8 +605,11 @@ export default function GeneratePage(): JSX.Element { const [librarySearchQuery, setLibrarySearchQuery] = useState('') const [librarySortMode, setLibrarySortMode] = useState('type') const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) + const [libraryThumbnails, setLibraryThumbnails] = useState>({}) + const [libraryPanelWidth, setLibraryPanelWidth] = useState(() => getStoredAssetLibraryPanelWidth()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) const dragging = useRef(false) + const libraryPanelDragging = useRef(false) // Populated by Viewer3D — undoes the latest live gizmo transform, if any. const gizmoUndoRef = useRef<(() => boolean) | null>(null) @@ -640,6 +673,11 @@ export default function GeneratePage(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags }, [openPanel, libraryLoaded, libraryLoading]) + // Persist the library panel's width so a manual resize survives app restarts. + useEffect(() => { + storeAssetLibraryPanelWidth(libraryPanelWidth) + }, [libraryPanelWidth]) + async function handleUnloadAll() { await window.electron.model.unloadAll() setUnloadStatus('done') @@ -710,6 +748,7 @@ export default function GeneratePage(): JSX.Element { ? current : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) setLibraryLoaded(true) + void loadLibraryThumbnails(result.entries) } catch (err) { setLibraryLoaded(false) setLibraryEntries([]) @@ -720,6 +759,17 @@ export default function GeneratePage(): JSX.Element { } } + // Thumbnails are best-effort: only .glb/.gltf entries can have a rendered + // .thumb.png, and a missing one is silently skipped rather than surfaced. + async function loadLibraryThumbnails(entries: ProjectedAssetLibraryEntry[]) { + const candidates = entries.filter((entry) => entry.previewKind === '3d-model') + const results = await Promise.all(candidates.map(async (entry) => { + const result = await assetLibraryService.thumbnail({ workspacePath: entry.workspacePath }) + return [entry.workspacePath, result.success ? result.dataUrl : null] as const + })) + setLibraryThumbnails(Object.fromEntries(results.filter((pair): pair is [string, string] => pair[1] !== null))) + } + async function handleOpenSelectedLibraryEntry() { const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null if (!selectedEntry) { @@ -806,6 +856,23 @@ export default function GeneratePage(): JSX.Element { window.addEventListener('mouseup', onMouseUp) }, []) + const onLibraryPanelResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + libraryPanelDragging.current = true + + const onMouseMove = (ev: MouseEvent) => { + if (!libraryPanelDragging.current) return + setLibraryPanelWidth((w) => clampAssetLibraryPanelWidth(w + ev.movementX)) + } + const onMouseUp = () => { + libraryPanelDragging.current = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + } + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + }, []) + return ( <>
@@ -929,6 +996,8 @@ export default function GeneratePage(): JSX.Element { searchQuery={librarySearchQuery} sortMode={librarySortMode} collapsedSectionKeys={libraryCollapsedSectionKeys} + thumbnails={libraryThumbnails} + panelWidth={libraryPanelWidth} onSelectEntry={(entryId) => { setLibraryError(null) setLibrarySelectedEntryId(entryId) @@ -939,6 +1008,7 @@ export default function GeneratePage(): JSX.Element { onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} onRefresh={() => { void loadLibraryEntries() }} onClose={() => setOpenPanel(null)} + onResizeMouseDown={onLibraryPanelResizeMouseDown} /> )}
diff --git a/src/areas/generate/assetLibraryService.test.ts b/src/areas/generate/assetLibraryService.test.ts index 9e7938c4..0ac08bce 100644 --- a/src/areas/generate/assetLibraryService.test.ts +++ b/src/areas/generate/assetLibraryService.test.ts @@ -50,6 +50,7 @@ test('renderer service delegates safe IPC calls and normalizes returned entries' }), read: async () => ({ success: false, error: { code: 'missing', message: 'Missing' } }), open: async (request) => { openRequest = request; return { success: false, error: { code: 'missing', message: 'Missing' } } }, + thumbnail: async () => ({ success: false }), }) const result = await service.list() @@ -58,3 +59,21 @@ test('renderer service delegates safe IPC calls and normalizes returned entries' await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) assert.deepEqual(openRequest, { workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) }) + +test('renderer service validates thumbnail requests before IPC and never surfaces an error', async () => { + let invoked = false + const service = createAssetLibraryService({ + list: async () => ({ success: true, entries: [] }), + read: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }), + open: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }), + thumbnail: async () => { invoked = true; return { success: true, dataUrl: 'data:image/png;base64,ok' } }, + }) + + const unsafe = await service.thumbnail({ workspacePath: '../escape.glb' }) + assert.deepEqual(unsafe, { success: false }) + assert.equal(invoked, false) + + const safe = await service.thumbnail({ workspacePath: 'Exports/hero.glb' }) + assert.deepEqual(safe, { success: true, dataUrl: 'data:image/png;base64,ok' }) + assert.equal(invoked, true) +}) diff --git a/src/areas/generate/assetLibraryService.ts b/src/areas/generate/assetLibraryService.ts index 38738579..5c18d49d 100644 --- a/src/areas/generate/assetLibraryService.ts +++ b/src/areas/generate/assetLibraryService.ts @@ -5,6 +5,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../shared/types/assetLibrary' import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' @@ -12,6 +14,7 @@ export interface AssetLibraryPreloadApi { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } export type ProjectedAssetLibraryListResult = @@ -60,6 +63,12 @@ export function createAssetLibraryService(api: AssetLibraryPreloadApi) { if (!result.success) return result return { success: true, entry: projectAssetLibraryEntry(result.entry) } }, + // A missing/invalid thumbnail is never an error the UI needs to show — the + // Library list just falls back to its text-only row. + async thumbnail(request: AssetLibraryThumbnailRequest): Promise { + if (validateWorkspacePath(request.workspacePath)) return { success: false } + return api.thumbnail(request) + }, } } diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts index e6b9fbca..681c292e 100644 --- a/src/areas/generate/assetLibraryUi.test.ts +++ b/src/areas/generate/assetLibraryUi.test.ts @@ -3,12 +3,15 @@ import test from 'node:test' import { buildAssetLibraryOpenRequest, + clampAssetLibraryPanelWidth, createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, type GenerateOpenPanel, @@ -123,3 +126,12 @@ test('builds linked-source open requests and import jobs for safe sidecars', () assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb') assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb') }) + +test('Library panel width clamps to sane bounds and degrades silently without a localStorage', () => { + assert.equal(clampAssetLibraryPanelWidth(0), 260) + assert.equal(clampAssetLibraryPanelWidth(999), 560) + assert.equal(clampAssetLibraryPanelWidth(400), 400) + // node --test has no `localStorage` global — both helpers must fall back rather than throw. + assert.equal(getStoredAssetLibraryPanelWidth(), 320) + assert.doesNotThrow(() => storeAssetLibraryPanelWidth(400)) +}) diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts index 5e83e65a..295dd185 100644 --- a/src/areas/generate/assetLibraryUi.ts +++ b/src/areas/generate/assetLibraryUi.ts @@ -168,6 +168,35 @@ export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpen return currentPanel === 'library' ? 'library' : currentPanel } +export const ASSET_LIBRARY_PANEL_MIN_WIDTH = 260 +export const ASSET_LIBRARY_PANEL_MAX_WIDTH = 560 +export const ASSET_LIBRARY_PANEL_DEFAULT_WIDTH = 320 + +const ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY = 'modly-library-panel-width' + +export function clampAssetLibraryPanelWidth(width: number): number { + return Math.min(ASSET_LIBRARY_PANEL_MAX_WIDTH, Math.max(ASSET_LIBRARY_PANEL_MIN_WIDTH, width)) +} + +/** Reads the user's saved Library panel width, falling back to the default when unset, invalid, or unreadable. */ +export function getStoredAssetLibraryPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampAssetLibraryPanelWidth(raw) : ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } catch { + return ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } +} + +/** Persists the Library panel width so it survives app restarts. */ +export function storeAssetLibraryPanelWidth(width: number): void { + try { + localStorage.setItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore quota/private-mode failures — the panel just won't remember its size. + } +} + function sortAssetLibraryEntries( entries: ProjectedAssetLibraryEntry[], sortMode: AssetLibrarySortMode, diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts index fb5a602d..87c48785 100644 --- a/src/shared/types/assetLibrary.ts +++ b/src/shared/types/assetLibrary.ts @@ -81,3 +81,13 @@ export type AssetLibraryReadResult = export type AssetLibraryOpenResult = | { success: true, entry: AssetLibraryEntry } | { success: false, error: AssetLibraryError } + +export interface AssetLibraryThumbnailRequest { + workspacePath: string +} + +// Missing thumbnails are an expected, silent condition (not every asset has +// a rendered preview), so failure carries no error payload for the UI to show. +export type AssetLibraryThumbnailResult = + | { success: true, dataUrl: string } + | { success: false } diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index f631f6d5..e18ca4bd 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -7,6 +7,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from './assetLibrary' // ─── Extension types ────────────────────────────────────────────────────────── @@ -250,6 +252,7 @@ declare global { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } } setup: { From 0c7eb467d000b765eec0baa6fe7b823c424762e7 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 17:42:40 -0700 Subject: [PATCH 03/20] Refresh package-lock.json npm ci fails on main because the committed lockfile is stale against package.json; npm install (what upstream CI itself falls back to) brings it back in sync. Committing the result so ci is usable again on this branch. Co-Authored-By: Claude Fable 5 --- package-lock.json | 440 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 432 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f336a2b..944bd257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modly", - "version": "0.3.5", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modly", - "version": "0.3.5", + "version": "0.4.1", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@mkkellogg/gaussian-splats-3d": "^0.4.7", @@ -25,6 +25,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/node": "^22.10.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", @@ -37,9 +38,12 @@ "electron-builder": "^24.13.3", "electron-vite": "^2.3.0", "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.6.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", + "typescript-eslint": "^8.61.1", "vite": "^5.4.0" } }, @@ -1193,6 +1197,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", @@ -1206,10 +1223,11 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2470,6 +2488,301 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -5137,6 +5450,26 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5165,6 +5498,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5716,10 +6062,11 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5845,6 +6192,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", @@ -8314,6 +8678,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8392,6 +8769,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9168,6 +9569,29 @@ "node": ">= 10" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", From 333edb2cf54065df6d4c8476c59c867331be6a7d Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 17:42:47 -0700 Subject: [PATCH 04/20] Add Ctrl/Cmd zoom shortcuts to the main process Ctrl/Cmd with +, -, or 0 now scales the whole window like a browser, clamped to [-2, 4] and persisted to /ui-zoom.json so it survives restarts. This extends the existing before-input-event handler (where the macOS quit shortcut already lives) and calls webContents.setZoomLevel directly, deliberately not CSS zoom: a CSS transform scales the rendered pixels without telling the 3D viewport, so pointer math there (raycasting, gizmo dragging) goes wrong as soon as the page is scaled. Co-Authored-By: Claude Fable 5 --- electron/main/index.ts | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/electron/main/index.ts b/electron/main/index.ts index 8cf9c810..388ae4a4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, shell, session } from 'electron' import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { setupIpcHandlers } from './ipc-handlers' import { PythonBridge } from './python-bridge' @@ -16,6 +17,37 @@ let pythonBridge: PythonBridge | null = null process.stdout?.on('error', () => {}) process.stderr?.on('error', () => {}) +// UI zoom: Ctrl/Cmd with + / - / 0 scales the whole window like a browser, +// clamped to a sane range and persisted across restarts. This is done at the +// Chromium level (not CSS) so pointer math in the 3D viewport stays correct. +const ZOOM_MIN = -2 +const ZOOM_MAX = 4 + +function zoomFilePath(): string { + return join(app.getPath('userData'), 'ui-zoom.json') +} + +function loadZoomLevel(): number { + try { + const saved = JSON.parse(readFileSync(zoomFilePath(), 'utf-8')) as { level?: number } + return typeof saved.level === 'number' ? saved.level : 0 + } catch { + return 0 + } +} + +function saveZoomLevel(level: number): void { + try { + writeFileSync(zoomFilePath(), JSON.stringify({ level }), 'utf-8') + } catch (err) { + logger.error(`Failed to persist UI zoom level: ${err}`) + } +} + +function clampZoomLevel(level: number): number { + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)) +} + function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, @@ -57,6 +89,33 @@ function createWindow(): void { event.preventDefault() app.quit() } + + const isZoomChord = input.type === 'keyDown' && (input.control || input.meta) && !input.alt + if (!isZoomChord) return + + const wc = mainWindow?.webContents + if (!wc) return + + if (input.key === '+' || input.key === '=') { + event.preventDefault() + const level = clampZoomLevel(wc.getZoomLevel() + 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '-' || input.key === '_') { + event.preventDefault() + const level = clampZoomLevel(wc.getZoomLevel() - 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '0') { + event.preventDefault() + wc.setZoomLevel(0) + saveZoomLevel(0) + } + }) + + mainWindow.webContents.on('did-finish-load', () => { + const level = loadZoomLevel() + if (level) mainWindow?.webContents.setZoomLevel(level) }) mainWindow.webContents.setWindowOpenHandler((details) => { From be4de10e1079a9a7c65a158f05d46ebbb2cc9265 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 17:42:52 -0700 Subject: [PATCH 05/20] Widen and remember the generate options panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's resize handle was a 1px strip with no resting affordance — easy to miss entirely. Widen it to 2px with a visible resting tint plus a stronger hover/active highlight, mark it up as a proper separator (role, aria-orientation, title) matching the Library panel's handle, raise the width cap from 520 to 900 so wide parameter forms have room, and persist the chosen width to localStorage (modly-panel-width) the same way the Library panel already remembers its own width. Co-Authored-By: Claude Fable 5 --- src/areas/generate/GeneratePage.tsx | 44 +++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index e258ecb4..b9e62121 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -27,9 +27,34 @@ import { } from './assetLibraryUi' const MIN_WIDTH = 220 -const MAX_WIDTH = 520 +const MAX_WIDTH = 900 const DEFAULT_WIDTH = 320 +const PANEL_WIDTH_STORAGE_KEY = 'modly-panel-width' + +function clampPanelWidth(width: number): number { + return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)) +} + +/** Reads the user's saved Generate panel width, falling back to the default when unset, invalid, or unreadable. */ +function getStoredPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampPanelWidth(raw) : DEFAULT_WIDTH + } catch { + return DEFAULT_WIDTH + } +} + +/** Persists the Generate panel width so it survives app restarts. */ +function storePanelWidth(width: number): void { + try { + localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore quota/private-mode failures — the panel just won't remember its size. + } +} + // --------------------------------------------------------------------------- // Export dropdown // --------------------------------------------------------------------------- @@ -591,7 +616,7 @@ function AssetLibraryPopover({ export default function GeneratePage(): JSX.Element { const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') - const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) + const [panelWidth, setPanelWidth] = useState(() => getStoredPanelWidth()) const [openPanel, setOpenPanel] = useState(null) const [decimating, setDecimating] = useState(false) const [smoothing, setSmoothing] = useState(false) @@ -678,6 +703,11 @@ export default function GeneratePage(): JSX.Element { storeAssetLibraryPanelWidth(libraryPanelWidth) }, [libraryPanelWidth]) + // Persist the generate options panel's width so a manual resize survives app restarts. + useEffect(() => { + storePanelWidth(panelWidth) + }, [panelWidth]) + async function handleUnloadAll() { await window.electron.model.unloadAll() setUnloadStatus('done') @@ -845,7 +875,7 @@ export default function GeneratePage(): JSX.Element { const onMouseMove = (ev: MouseEvent) => { if (!dragging.current) return - setPanelWidth((w) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w + ev.movementX))) + setPanelWidth((w) => clampPanelWidth(w + ev.movementX)) } const onMouseUp = () => { dragging.current = false @@ -879,10 +909,14 @@ export default function GeneratePage(): JSX.Element {
- {/* Resize handle */} + {/* Resize handle — drag to widen/narrow the generate options panel; size is persisted. */}
From 49bc54271d7f1290cdf4badfdc0632defc1e5159 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 17:43:12 -0700 Subject: [PATCH 06/20] Improve Generate form ergonomics and add tag suggestions - Parameter labels used a truncating 5rem column that clipped anything longer than a couple of words; widen it to 8rem and let it wrap. - The prompt textarea was a fixed 3-row, non-resizable box; make it 14 rows by default and user-resizable (resize-y, 14rem min-height). - Say plainly which Generate inputs are required vs optional: "Your sketch or photo -- required" and "Extra details -- optional". - Add a text param type (a plain input, unlike the existing string type folder picker) so free-text fields like the exporter new name, project, and tags params render as usable text boxes instead of falling through to a numeric input. - Render clickable tag-suggestion pills beneath any tags param: slugified words from that node typed name/project fields, plus a small fixed game-asset vocabulary, deduped and capped at 10. Clicking a pill toggles it in the comma-separated value; no network calls. Co-Authored-By: Claude Fable 5 --- .../generate/components/WorkflowPanel.tsx | 110 ++++++++++++++++-- src/shared/types/electron.d.ts | 2 +- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index ffc774c7..9f90f79c 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -59,6 +59,50 @@ function mimeFromPath(p: string): string { return 'image/png' } +// ─── Tag suggestions ────────────────────────────────────────────────────────── + +const TAG_STOPWORDS = new Set([ + 'a', 'an', 'the', 'of', 'for', 'and', 'or', 'to', 'in', 'on', 'my', 'this', 'is', 'with', +]) + +const FIXED_TAG_VOCAB = [ + 'prop', 'character', 'creature', 'environment', 'weapon', + 'furniture', 'hero-asset', 'background', 'low-poly', 'stylized', 'realistic', +] + +const MAX_TAG_SUGGESTIONS = 10 + +/** Slugified words pulled from the typed name/project, plus a small fixed vocabulary — capped and deduped. */ +function suggestedTags(modelName: string, project: string): string[] { + const fromFields = `${modelName} ${project}` + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 1 && !TAG_STOPWORDS.has(word)) + + const seen = new Set() + const suggestions: string[] = [] + for (const tag of [...fromFields, ...FIXED_TAG_VOCAB]) { + if (!tag || seen.has(tag)) continue + seen.add(tag) + suggestions.push(tag) + if (suggestions.length >= MAX_TAG_SUGGESTIONS) break + } + return suggestions +} + +function parseTags(value: string): string[] { + return value.split(',').map((s) => s.trim()).filter(Boolean) +} + +/** Appends a tag to the comma-separated value, or removes it if already present. */ +function toggleTag(value: string, tag: string): string { + const tags = parseTags(value) + const i = tags.indexOf(tag) + if (i >= 0) tags.splice(i, 1) + else tags.push(tag) + return tags.join(', ') +} + // ─── Param field ────────────────────────────────────────────────────────────── const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' @@ -141,6 +185,12 @@ function ParamField({ param, value, onChange }: {
) } + if (param.type === 'text') { + return ( + onChange(e.target.value)} className={inputCls} /> + ) + } if (param.type === 'float') { return onChange(v)} className={inputCls} /> } @@ -148,6 +198,37 @@ function ParamField({ param, value, onChange }: { return onChange(v)} className={inputCls} /> } +function TagSuggestionPills({ value, onChange, modelName, project }: { + value: string + onChange: (v: string) => void + modelName: string + project: string +}) { + const suggestions = useMemo(() => suggestedTags(modelName, project), [modelName, project]) + if (suggestions.length === 0) return null + + const active = new Set(parseTags(value)) + + return ( +
+ {suggestions.map((tag) => ( + + ))} +
+ ) +} + // ─── Workflow dropdown ──────────────────────────────────────────────────────── function WorkflowDropdown({ workflows, value, onChange }: { @@ -226,7 +307,7 @@ function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: Flow - Image + Your sketch or photo — required
{preview ? (