diff --git a/packages/editor/packages/editor-state/src/effects/menu/categoryTree.test.ts b/packages/editor/packages/editor-state/src/effects/menu/categoryTree.test.ts new file mode 100644 index 000000000..2b7f8e34a --- /dev/null +++ b/packages/editor/packages/editor-state/src/effects/menu/categoryTree.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; + +import { buildCategoryTree, flattenCategoryPaths, findNodeByPath, type CategoryItem } from './categoryTree'; + +describe('categoryTree', () => { + describe('buildCategoryTree', () => { + it('should build a flat tree from single-level categories', () => { + const items: CategoryItem[] = [ + { slug: 'item1', title: 'Item 1', category: 'Audio' }, + { slug: 'item2', title: 'Item 2', category: 'Effects' }, + { slug: 'item3', title: 'Item 3', category: 'Audio' }, + ]; + + const tree = buildCategoryTree(items); + + expect(tree.children).toHaveLength(2); + expect(tree.children[0].label).toBe('Audio'); + expect(tree.children[0].items).toHaveLength(2); + expect(tree.children[1].label).toBe('Effects'); + expect(tree.children[1].items).toHaveLength(1); + }); + + it('should build a nested tree from slash-delimited categories', () => { + const items: CategoryItem[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid', category: 'Functions/Activation' }, + { slug: 'quadratic', title: 'Quadratic', category: 'Functions/Polynomial' }, + ]; + + const tree = buildCategoryTree(items); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].label).toBe('Functions'); + expect(tree.children[0].path).toBe('Functions'); + expect(tree.children[0].children).toHaveLength(3); + + const trigNode = tree.children[0].children.find(c => c.label === 'Trigonometric'); + expect(trigNode).toBeDefined(); + expect(trigNode!.path).toBe('Functions/Trigonometric'); + expect(trigNode!.items).toHaveLength(1); + expect(trigNode!.items[0].slug).toBe('sine'); + }); + + it('should sort categories and items alphabetically', () => { + const items: CategoryItem[] = [ + { slug: 'item1', title: 'Zebra', category: 'Zoo' }, + { slug: 'item2', title: 'Apple', category: 'Fruit' }, + { slug: 'item3', title: 'Banana', category: 'Fruit' }, + { slug: 'item4', title: 'Yak', category: 'Zoo' }, + ]; + + const tree = buildCategoryTree(items); + + // Categories should be alphabetical + expect(tree.children[0].label).toBe('Fruit'); + expect(tree.children[1].label).toBe('Zoo'); + + // Items within categories should be alphabetical + expect(tree.children[0].items[0].title).toBe('Apple'); + expect(tree.children[0].items[1].title).toBe('Banana'); + expect(tree.children[1].items[0].title).toBe('Yak'); + expect(tree.children[1].items[1].title).toBe('Zebra'); + }); + + it('should handle empty categories by using "Uncategorized"', () => { + const items: CategoryItem[] = [ + { slug: 'item1', title: 'Item 1', category: '' }, + { slug: 'item2', title: 'Item 2', category: 'Audio' }, + ]; + + const tree = buildCategoryTree(items); + + expect(tree.children).toHaveLength(2); + const uncategorized = tree.children.find(c => c.label === 'Uncategorized'); + expect(uncategorized).toBeDefined(); + expect(uncategorized!.items).toHaveLength(1); + }); + + it('should handle mixed depth categories', () => { + const items: CategoryItem[] = [ + { slug: 'item1', title: 'Item 1', category: 'Audio' }, + { slug: 'item2', title: 'Item 2', category: 'Audio/Effects' }, + { slug: 'item3', title: 'Item 3', category: 'Audio/Effects/Reverb' }, + ]; + + const tree = buildCategoryTree(items); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].label).toBe('Audio'); + expect(tree.children[0].items).toHaveLength(1); + expect(tree.children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].label).toBe('Effects'); + expect(tree.children[0].children[0].items).toHaveLength(1); + expect(tree.children[0].children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].children[0].label).toBe('Reverb'); + expect(tree.children[0].children[0].children[0].items).toHaveLength(1); + }); + + it('should trim whitespace from category segments', () => { + const items: CategoryItem[] = [{ slug: 'item1', title: 'Item 1', category: ' Audio / Effects ' }]; + + const tree = buildCategoryTree(items); + + expect(tree.children[0].label).toBe('Audio'); + expect(tree.children[0].children[0].label).toBe('Effects'); + }); + }); + + describe('flattenCategoryPaths', () => { + it('should return all category paths in a flat list', () => { + const items: CategoryItem[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid', category: 'Functions/Activation' }, + { slug: 'audio1', title: 'Audio 1', category: 'Audio' }, + ]; + + const tree = buildCategoryTree(items); + const paths = flattenCategoryPaths(tree); + + expect(paths).toContain('Functions'); + expect(paths).toContain('Functions/Trigonometric'); + expect(paths).toContain('Functions/Activation'); + expect(paths).toContain('Audio'); + }); + + it('should return empty array for empty tree', () => { + const tree = buildCategoryTree([]); + const paths = flattenCategoryPaths(tree); + + expect(paths).toHaveLength(0); + }); + }); + + describe('findNodeByPath', () => { + const items: CategoryItem[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid', category: 'Functions/Activation' }, + { slug: 'audio1', title: 'Audio 1', category: 'Audio' }, + ]; + + it('should find a node by its full path', () => { + const tree = buildCategoryTree(items); + const node = findNodeByPath(tree, 'Functions/Trigonometric'); + + expect(node).not.toBeNull(); + expect(node!.label).toBe('Trigonometric'); + expect(node!.items).toHaveLength(1); + expect(node!.items[0].slug).toBe('sine'); + }); + + it('should find intermediate nodes', () => { + const tree = buildCategoryTree(items); + const node = findNodeByPath(tree, 'Functions'); + + expect(node).not.toBeNull(); + expect(node!.label).toBe('Functions'); + expect(node!.children).toHaveLength(2); + }); + + it('should return root for empty path', () => { + const tree = buildCategoryTree(items); + const node = findNodeByPath(tree, ''); + + expect(node).toBe(tree); + }); + + it('should return null for non-existent path', () => { + const tree = buildCategoryTree(items); + const node = findNodeByPath(tree, 'NonExistent/Path'); + + expect(node).toBeNull(); + }); + }); +}); diff --git a/packages/editor/packages/editor-state/src/effects/menu/categoryTree.ts b/packages/editor/packages/editor-state/src/effects/menu/categoryTree.ts new file mode 100644 index 000000000..6c2cb097b --- /dev/null +++ b/packages/editor/packages/editor-state/src/effects/menu/categoryTree.ts @@ -0,0 +1,146 @@ +/** + * Utility for building nested category trees from flat lists with slash-delimited categories. + */ + +export interface CategoryItem { + slug: string; + title: string; + category: string; +} + +export interface CategoryTreeNode { + /** Path segment at this level (e.g., "Functions" in "Functions/Trigonometric") */ + label: string; + /** Full path to this node (e.g., "Functions/Trigonometric") */ + path: string; + /** Child category nodes */ + children: CategoryTreeNode[]; + /** Leaf items at this category level */ + items: CategoryItem[]; +} + +/** + * Builds a nested category tree from a flat list of items with slash-delimited categories. + * Categories are split by "/" to create hierarchy (e.g., "Functions/Trigonometric"). + * Empty categories default to "Uncategorized". + * Results are sorted alphabetically at each level. + * + * @param items - List of items with category, slug, and title + * @returns Root node containing the tree structure + */ +export function buildCategoryTree(items: CategoryItem[]): CategoryTreeNode { + const root: CategoryTreeNode = { + label: '', + path: '', + children: [], + items: [], + }; + + // Group items by their full category path + const categorizedItems = items.map(item => ({ + ...item, + category: item.category || 'Uncategorized', + })); + + // Build tree structure + for (const item of categorizedItems) { + const segments = item.category.split('/').filter(s => s.trim()); + let currentNode = root; + let currentPath = ''; + + // Navigate/create path through tree + for (let i = 0; i < segments.length; i++) { + const segment = segments[i].trim(); + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + + // Find or create child node for this segment + let childNode = currentNode.children.find(c => c.label === segment); + if (!childNode) { + childNode = { + label: segment, + path: currentPath, + children: [], + items: [], + }; + currentNode.children.push(childNode); + } + currentNode = childNode; + } + + // Add item to the leaf node + currentNode.items.push(item); + } + + // Sort all levels alphabetically + sortTreeNode(root); + + return root; +} + +/** + * Recursively sorts a tree node's children and items alphabetically. + */ +function sortTreeNode(node: CategoryTreeNode): void { + // Sort child nodes by label + node.children.sort((a, b) => a.label.localeCompare(b.label)); + + // Sort items by title + node.items.sort((a, b) => a.title.localeCompare(b.title)); + + // Recursively sort children + for (const child of node.children) { + sortTreeNode(child); + } +} + +/** + * Flattens a category tree into a list of category paths. + * Used for generating category menu items. + * + * @param node - Root or intermediate tree node + * @returns List of category paths (e.g., ["Audio", "Functions/Trigonometric"]) + */ +export function flattenCategoryPaths(node: CategoryTreeNode): string[] { + const paths: string[] = []; + + function traverse(n: CategoryTreeNode) { + // Add this node's path if it has items or children + if (n.path && (n.items.length > 0 || n.children.length > 0)) { + paths.push(n.path); + } + + // Traverse children + for (const child of n.children) { + traverse(child); + } + } + + traverse(node); + return paths; +} + +/** + * Finds a node in the tree by its full path. + * + * @param root - Root tree node + * @param path - Full category path (e.g., "Functions/Trigonometric") + * @returns The node at the path, or null if not found + */ +export function findNodeByPath(root: CategoryTreeNode, path: string): CategoryTreeNode | null { + if (!path) { + return root; + } + + const segments = path.split('/').filter(s => s.trim()); + let currentNode: CategoryTreeNode | null = root; + + for (const segment of segments) { + const nextNode: CategoryTreeNode | undefined = currentNode?.children.find(c => c.label === segment); + if (!nextNode) { + return null; + } + currentNode = nextNode; + } + + return currentNode; +} diff --git a/packages/editor/packages/editor-state/src/effects/menu/menus.test.ts b/packages/editor/packages/editor-state/src/effects/menu/menus.test.ts new file mode 100644 index 000000000..418d4d9f2 --- /dev/null +++ b/packages/editor/packages/editor-state/src/effects/menu/menus.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { moduleCategoriesMenu, projectCategoriesMenu } from './menus'; + +import type { State, ModuleMetadata, ProjectMetadata } from '../../types'; + +describe('nested menu generation', () => { + describe('moduleCategoriesMenu', () => { + it('should generate top-level categories for modules', async () => { + const mockModules: ModuleMetadata[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid', category: 'Functions/Activation' }, + { slug: 'audio1', title: 'Audio 1', category: 'Audio' }, + ]; + + const mockState = { + callbacks: { + getListOfModules: vi.fn().mockResolvedValue(mockModules), + }, + } as unknown as State; + + const result = await moduleCategoriesMenu(mockState, {}); + + // Should have two top-level items: Audio and Functions + expect(result).toHaveLength(2); + expect(result[0].title).toBe('Audio'); + expect(result[0].action).toBe('openSubMenu'); + expect(result[1].title).toBe('Functions'); + expect(result[1].action).toBe('openSubMenu'); + }); + + it('should generate nested submenus for slash-delimited categories', async () => { + const mockModules: ModuleMetadata[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid', category: 'Functions/Activation' }, + ]; + + const mockState = { + callbacks: { + getListOfModules: vi.fn().mockResolvedValue(mockModules), + }, + } as unknown as State; + + // Navigate to Functions submenu + const functionsMenu = await moduleCategoriesMenu(mockState, { categoryPath: 'Functions' }); + + // Should have two subcategories + expect(functionsMenu).toHaveLength(2); + expect(functionsMenu[0].title).toBe('Activation'); + expect(functionsMenu[0].action).toBe('openSubMenu'); + expect(functionsMenu[1].title).toBe('Trigonometric'); + expect(functionsMenu[1].action).toBe('openSubMenu'); + }); + + it('should generate leaf items (modules) at the deepest level', async () => { + const mockModules: ModuleMetadata[] = [ + { slug: 'sine', title: 'Sine', category: 'Functions/Trigonometric' }, + { slug: 'cosine', title: 'Cosine', category: 'Functions/Trigonometric' }, + ]; + + const mockState = { + callbacks: { + getListOfModules: vi.fn().mockResolvedValue(mockModules), + }, + } as unknown as State; + + // Navigate to Functions/Trigonometric + const trigMenu = await moduleCategoriesMenu(mockState, { categoryPath: 'Functions/Trigonometric' }); + + // Should have two module items + expect(trigMenu).toHaveLength(2); + expect(trigMenu[0].title).toBe('Cosine'); + expect(trigMenu[0].action).toBe('addCodeBlockBySlug'); + expect(trigMenu[0].close).toBe(true); + expect(trigMenu[1].title).toBe('Sine'); + }); + + it('should handle flat categories without nesting', async () => { + const mockModules: ModuleMetadata[] = [ + { slug: 'audio1', title: 'Audio 1', category: 'Audio' }, + { slug: 'audio2', title: 'Audio 2', category: 'Audio' }, + ]; + + const mockState = { + callbacks: { + getListOfModules: vi.fn().mockResolvedValue(mockModules), + }, + } as unknown as State; + + // Get top-level menu + const topMenu = await moduleCategoriesMenu(mockState, {}); + expect(topMenu).toHaveLength(1); + expect(topMenu[0].title).toBe('Audio'); + + // Navigate to Audio category + const audioMenu = await moduleCategoriesMenu(mockState, { categoryPath: 'Audio' }); + expect(audioMenu).toHaveLength(2); + expect(audioMenu[0].title).toBe('Audio 1'); + expect(audioMenu[0].action).toBe('addCodeBlockBySlug'); + }); + + it('should sort categories and items alphabetically', async () => { + const mockModules: ModuleMetadata[] = [ + { slug: 'z', title: 'Zebra', category: 'Z' }, + { slug: 'a', title: 'Apple', category: 'A' }, + { slug: 'b', title: 'Banana', category: 'A' }, + ]; + + const mockState = { + callbacks: { + getListOfModules: vi.fn().mockResolvedValue(mockModules), + }, + } as unknown as State; + + const topMenu = await moduleCategoriesMenu(mockState, {}); + expect(topMenu[0].title).toBe('A'); + expect(topMenu[1].title).toBe('Z'); + + const aMenu = await moduleCategoriesMenu(mockState, { categoryPath: 'A' }); + expect(aMenu[0].title).toBe('Apple'); + expect(aMenu[1].title).toBe('Banana'); + }); + }); + + describe('projectCategoriesMenu', () => { + it('should generate top-level categories for projects', async () => { + const mockProjects: ProjectMetadata[] = [ + { slug: 'audio1', title: 'Audio Project', description: '', category: 'Audio' }, + { slug: 'midi1', title: 'MIDI Project', description: '', category: 'MIDI' }, + ]; + + const mockState = { + callbacks: { + getListOfProjects: vi.fn().mockResolvedValue(mockProjects), + getProject: vi.fn(), + }, + } as unknown as State; + + const result = await projectCategoriesMenu(mockState, {}); + + expect(result).toHaveLength(2); + expect(result[0].title).toBe('Audio'); + expect(result[1].title).toBe('MIDI'); + }); + + it('should generate leaf items (projects) at category level', async () => { + const mockProjects: ProjectMetadata[] = [ + { slug: 'audio1', title: 'Audio Project 1', description: '', category: 'Audio' }, + { slug: 'audio2', title: 'Audio Project 2', description: '', category: 'Audio' }, + ]; + + const mockState = { + callbacks: { + getListOfProjects: vi.fn().mockResolvedValue(mockProjects), + getProject: vi.fn(), + }, + } as unknown as State; + + const audioMenu = await projectCategoriesMenu(mockState, { categoryPath: 'Audio' }); + + expect(audioMenu).toHaveLength(2); + expect(audioMenu[0].title).toBe('Audio Project 1'); + expect(audioMenu[0].action).toBe('loadProjectBySlug'); + expect(audioMenu[0].payload).toEqual({ projectSlug: 'audio1' }); + }); + + it('should handle nested project categories', async () => { + const mockProjects: ProjectMetadata[] = [ + { slug: 'synth1', title: 'Synth 1', description: '', category: 'Audio/Synthesis' }, + { slug: 'synth2', title: 'Synth 2', description: '', category: 'Audio/Synthesis' }, + ]; + + const mockState = { + callbacks: { + getListOfProjects: vi.fn().mockResolvedValue(mockProjects), + getProject: vi.fn(), + }, + } as unknown as State; + + const topMenu = await projectCategoriesMenu(mockState, {}); + expect(topMenu).toHaveLength(1); + expect(topMenu[0].title).toBe('Audio'); + + const audioMenu = await projectCategoriesMenu(mockState, { categoryPath: 'Audio' }); + expect(audioMenu).toHaveLength(1); + expect(audioMenu[0].title).toBe('Synthesis'); + + const synthMenu = await projectCategoriesMenu(mockState, { categoryPath: 'Audio/Synthesis' }); + expect(synthMenu).toHaveLength(2); + expect(synthMenu[0].title).toBe('Synth 1'); + }); + }); +}); diff --git a/packages/editor/packages/editor-state/src/effects/menu/menus.ts b/packages/editor/packages/editor-state/src/effects/menu/menus.ts index 28785338c..63bcade93 100644 --- a/packages/editor/packages/editor-state/src/effects/menu/menus.ts +++ b/packages/editor/packages/editor-state/src/effects/menu/menus.ts @@ -1,3 +1,5 @@ +import { buildCategoryTree, findNodeByPath } from './categoryTree'; + import type { CodeBlockGraphicData, MenuGenerator, ContextMenuItem } from '../../types'; export const mainMenu: MenuGenerator = state => [ @@ -44,7 +46,7 @@ export const mainMenu: MenuGenerator = state => [ { title: 'Open Project', action: 'openSubMenu', - payload: { menu: 'projectMenu' }, + payload: { menu: 'projectCategoriesMenu' }, close: false, disabled: !state.callbacks.getListOfProjects, }, @@ -135,34 +137,43 @@ export const moduleMenu: MenuGenerator = state => { ]; }; -export const moduleCategoriesMenu: MenuGenerator = async state => { +export const moduleCategoriesMenu: MenuGenerator = async (state, payload = {}) => { if (!state.callbacks.getListOfModules) { return []; } + + const { categoryPath = '' } = payload as { categoryPath?: string }; const modules = await state.callbacks.getListOfModules(); - const categories = [...new Set(modules.map(module => module.category))]; - return categories.map(category => { - return { title: category, action: 'openSubMenu', payload: { menu: 'builtInModuleMenu', category }, close: false }; - }); -}; + const tree = buildCategoryTree(modules); -export const builtInModuleMenu: MenuGenerator = async (state, payload = {}) => { - const { category } = payload as { category: string }; - if (!state.callbacks.getListOfModules || !state.callbacks.getModule) { + // Find the current node in the tree + const currentNode = findNodeByPath(tree, categoryPath); + if (!currentNode) { return []; } - const modules = await state.callbacks.getListOfModules(); - const filteredModules = modules.filter(module => module.category === category); const menuItems: ContextMenuItem[] = []; - for (const moduleMetadata of filteredModules) { + + // Add submenu items for child categories + for (const childNode of currentNode.children) { menuItems.push({ - title: moduleMetadata.title, + title: childNode.label, + action: 'openSubMenu', + payload: { menu: 'moduleCategoriesMenu', categoryPath: childNode.path }, + close: false, + }); + } + + // Add leaf items (modules) at this level + for (const item of currentNode.items) { + menuItems.push({ + title: item.title, action: 'addCodeBlockBySlug', - payload: { codeBlockSlug: moduleMetadata.slug }, + payload: { codeBlockSlug: item.slug }, close: true, }); } + return menuItems; }; @@ -229,19 +240,42 @@ export const fontMenu: MenuGenerator = () => [ { title: '6x10', selector: 'editorSettings.font', value: '6x10', close: false }, ]; -export const projectMenu: MenuGenerator = async state => { +export const projectCategoriesMenu: MenuGenerator = async (state, payload = {}) => { if (!state.callbacks.getListOfProjects || !state.callbacks.getProject) { return []; } + + const { categoryPath = '' } = payload as { categoryPath?: string }; const projects = await state.callbacks.getListOfProjects(); + const tree = buildCategoryTree(projects); + + // Find the current node in the tree + const currentNode = findNodeByPath(tree, categoryPath); + if (!currentNode) { + return []; + } + const menuItems: ContextMenuItem[] = []; - for (const projectMetadata of projects) { + + // Add submenu items for child categories + for (const childNode of currentNode.children) { menuItems.push({ - title: projectMetadata.title, + title: childNode.label, + action: 'openSubMenu', + payload: { menu: 'projectCategoriesMenu', categoryPath: childNode.path }, + close: false, + }); + } + + // Add leaf items (projects) at this level + for (const item of currentNode.items) { + menuItems.push({ + title: item.title, action: 'loadProjectBySlug', - payload: { projectSlug: projectMetadata.slug }, + payload: { projectSlug: item.slug }, close: true, }); } + return menuItems; }; diff --git a/packages/editor/packages/editor-state/src/types.ts b/packages/editor/packages/editor-state/src/types.ts index 346a01008..50bdb9a9d 100644 --- a/packages/editor/packages/editor-state/src/types.ts +++ b/packages/editor/packages/editor-state/src/types.ts @@ -505,6 +505,7 @@ export interface ProjectMetadata { slug: string; title: string; description: string; + category: string; } /** diff --git a/src/examples/modules/index.ts b/src/examples/modules/index.ts index 6f438f425..746857738 100644 --- a/src/examples/modules/index.ts +++ b/src/examples/modules/index.ts @@ -122,7 +122,7 @@ export const moduleMetadata: ModuleMetadata[] = [ { slug: 'sigmoidPolynomialApproximation', title: 'Sigmoid Function (Polynomial Approximation)', - category: 'Machine Learning', + category: 'Machine Learning/Functions', }, { slug: 'sineLookupTable', title: 'Sine Lookup Table', category: 'Lookup Tables' }, { @@ -136,8 +136,8 @@ export const moduleMetadata: ModuleMetadata[] = [ { slug: 'switchGatesFloat', title: 'Switchable Gates (8x Float)', category: 'Controllers' }, { slug: 'switchGatesInt', title: 'Switchable Gates (8x Int)', category: 'Controllers' }, { slug: 'triangleSignedFloat', title: 'Triangle (Signed, Float)', category: 'Oscillators' }, - { slug: 'sine', title: 'Sine [-PI, PI] (Polynomial Approximation)', category: 'Functions' }, - { slug: 'sigmoid', title: 'Sigmoid (Polynomial Approximation)', category: 'Functions' }, + { slug: 'sine', title: 'Sine [-PI, PI] (Polynomial Approximation)', category: 'Functions/Trigonometric' }, + { slug: 'sigmoid', title: 'Sigmoid (Polynomial Approximation)', category: 'Functions/Activation' }, ]; // For backwards compatibility, export a default object that matches the old API diff --git a/src/examples/projects/index.ts b/src/examples/projects/index.ts index e4ba0e154..26318b5e5 100644 --- a/src/examples/projects/index.ts +++ b/src/examples/projects/index.ts @@ -32,27 +32,29 @@ export const projectManifest: Record Promise> = Object.fr * Metadata is kept in sync with actual project files. */ export const projectMetadata: ProjectMetadata[] = [ - { slug: 'audioBuffer', title: 'Audio Buffer', description: '' }, - { slug: 'audioLoopback', title: 'Audio Loopback', description: '' }, - { slug: 'bistableMultivibrators', title: 'Bistable Multivibrators', description: '' }, + { slug: 'audioBuffer', title: 'Audio Buffer', description: '', category: 'Audio' }, + { slug: 'audioLoopback', title: 'Audio Loopback', description: '', category: 'Audio' }, + { slug: 'bistableMultivibrators', title: 'Bistable Multivibrators', description: '', category: 'Logic' }, { slug: 'crtEffect', title: 'CRT Effect Demo', description: 'Demonstrates post-process shader effects with a classic CRT monitor appearance', + category: 'Effects', }, - { slug: 'dancingWithTheSineLT', title: 'Dancing With The Sine LT', description: '' }, - { slug: 'ericSaiteGenerator', title: 'Eric Saite Generator', description: '' }, - { slug: 'midiArpeggiator', title: 'MIDI Arpeggiator', description: '' }, - { slug: 'midiArpeggiator2', title: 'MIDI Arpeggiator 2', description: '' }, - { slug: 'midiBreakBeat', title: 'MIDI Break Beat', description: '' }, - { slug: 'midiBreakBreak2dSequencer', title: 'MIDI Break Break 2D Sequencer', description: '' }, - { slug: 'neuralNetwork', title: 'Neural Network', description: '' }, - { slug: 'randomGenerators', title: 'Random Generators', description: '' }, - { slug: 'randomNoteGenerator', title: 'Random Note Generator', description: '' }, + { slug: 'dancingWithTheSineLT', title: 'Dancing With The Sine LT', description: '', category: 'Audio' }, + { slug: 'ericSaiteGenerator', title: 'Eric Saite Generator', description: '', category: 'Audio' }, + { slug: 'midiArpeggiator', title: 'MIDI Arpeggiator', description: '', category: 'MIDI' }, + { slug: 'midiArpeggiator2', title: 'MIDI Arpeggiator 2', description: '', category: 'MIDI' }, + { slug: 'midiBreakBeat', title: 'MIDI Break Beat', description: '', category: 'MIDI' }, + { slug: 'midiBreakBreak2dSequencer', title: 'MIDI Break Break 2D Sequencer', description: '', category: 'MIDI' }, + { slug: 'neuralNetwork', title: 'Neural Network', description: '', category: 'Machine Learning' }, + { slug: 'randomGenerators', title: 'Random Generators', description: '', category: 'Generators' }, + { slug: 'randomNoteGenerator', title: 'Random Note Generator', description: '', category: 'Generators' }, { slug: 'simpleCounterMainThread', title: 'Simple Counter (Main Thread)', description: 'Demonstrates the MainThreadLogicRuntime', + category: 'Examples', }, ];