Parent Issue
Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation
Problem
While custom entity types can be created and used after implementing #446 and #447, the rendering experience needs polish to ensure custom entities look professional and are easy to work with.
Current state (after #446 + #447):
- Custom entity nodes can be created ✅
- They render with BaseNode (basic content editing) ✅
- SchemaPropertyForm exists but needs integration verification ✅
- Generic icons used (text-circle fallback) ⚠️
- No fallback UI for entities without schemas ❌
- Unclear visual distinction from core types ⚠️
Desired state:
- Custom entities render cleanly with full property support ✅
- Missing schemas show helpful error state ✅
- Visual styling distinguishes custom from core types ✅
- Icons can be customized per entity type ✅
- Performance is equivalent to core types ✅
Solution
Polish the generic custom entity rendering experience with focus on:
- Component Integration - Ensure BaseNode + SchemaPropertyForm work seamlessly
- Error Handling - Graceful fallbacks for missing schemas
- Visual Polish - Clear distinction and professional appearance
- Icon System - Support for custom icons (optional metadata field)
- Performance - No degradation vs. core types
Architecture
Component Hierarchy:
CustomEntityNode (wrapper)
├─ BaseNode (content editing)
└─ SchemaPropertyForm (property editing)
Fallback Chain:
1. Try custom component (if registered)
2. Fall back to CustomEntityNode
3. If no schema: Show error state
4. If schema exists: Render with SchemaPropertyForm
Implementation Details
1. Custom Entity Node Component
File: packages/desktop-app/src/lib/design/components/custom-entity-node.svelte
<script lang="ts">
import BaseNode from './base-node.svelte';
import SchemaPropertyForm from '$lib/components/property-forms/schema-property-form.svelte';
import { schemaService } from '$lib/services/schema-service';
import type { Node } from '$lib/types';
let {
nodeId,
nodeType,
content = '',
properties = {},
metadata = {}
}: {
nodeId: string;
nodeType: string;
content?: string;
properties?: Record<string, unknown>;
metadata?: Record<string, unknown>;
} = $props();
// Load schema for this entity type
let schema = $state(null);
let schemaError = $state<string | null>(null);
let isLoadingSchema = $state(true);
$effect(() => {
async function loadSchema() {
isLoadingSchema = true;
schemaError = null;
try {
schema = await schemaService.getSchema(nodeType);
} catch (error) {
console.error(`[CustomEntityNode] Failed to load schema for ${nodeType}:`, error);
schemaError = error instanceof Error ? error.message : 'Failed to load schema';
schema = null;
} finally {
isLoadingSchema = false;
}
}
loadSchema();
});
// Get custom icon from schema metadata (if available)
const customIcon = $derived(schema?.properties?.icon || null);
const entityName = $derived(schema?.content || nodeType);
</script>
<div class="custom-entity-node" data-entity-type={nodeType}>
<!-- Entity Header (optional, shows entity type name) -->
{#if schema && schema.properties?.show_entity_header !== false}
<div class="entity-header">
{#if customIcon}
<span class="entity-icon">{customIcon}</span>
{/if}
<span class="entity-type-name">{entityName}</span>
</div>
{/if}
<!-- Base Content Editing -->
<BaseNode
{nodeId}
{nodeType}
bind:content
{properties}
{metadata}
/>
<!-- Schema Properties (if schema exists) -->
{#if isLoadingSchema}
<div class="schema-loading">
<span class="text-sm text-muted-foreground">Loading properties...</span>
</div>
{:else if schemaError}
<div class="schema-error">
<span class="text-sm text-destructive">
⚠️ Schema not found for "{nodeType}".
<button
class="create-schema-link"
onclick={() => window.createSchemaForType?.(nodeType)}
>
Create schema
</button>
</span>
</div>
{:else if schema}
<SchemaPropertyForm {nodeId} {nodeType} />
{/if}
</div>
<style>
.custom-entity-node {
border-left: 3px solid var(--custom-entity-accent, #6366f1);
padding-left: 0.75rem;
}
.entity-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--muted);
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.entity-icon {
font-size: 1rem;
}
.entity-type-name {
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
}
.schema-loading,
.schema-error {
padding: 0.5rem;
text-align: center;
}
.schema-error {
background: var(--destructive-foreground);
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.create-schema-link {
color: var(--primary);
text-decoration: underline;
background: none;
border: none;
cursor: pointer;
padding: 0;
font: inherit;
}
</style>
2. Integration with Plugin Registry
Update schema-plugin-loader.ts (from #447) to register CustomEntityNode:
export function createPluginFromSchema(
schema: SchemaDefinition,
schemaId: string
): PluginDefinition {
return {
id: schemaId,
name: schema.content || schemaId,
config: {
slashCommands: [...],
canHaveChildren: true,
canBeChild: true
},
node: {
// Use custom entity component
component: CustomEntityNode, // ← Direct reference (not lazy)
// OR lazy load:
// lazyLoad: () => import('../design/components/custom-entity-node.svelte')
}
};
}
3. Icon System (Schema Metadata)
Allow schemas to specify custom icons:
{
"id": "invoice",
"node_type": "schema",
"properties": {
"is_core": false,
"icon": "💰", // Emoji or icon identifier
"show_entity_header": true, // Show entity type badge
"description": "Invoice tracking",
"fields": [...]
}
}
Update slash command dropdown to use schema icons:
// In slash-command-dropdown.svelte
function getCommandIcon(command: SlashCommand): string {
// Try to get icon from schema
const schema = schemaService.getCachedSchema(command.nodeType);
if (schema?.properties?.icon) {
return schema.properties.icon;
}
// Fall back to hardcoded core icons or generic
return getDefaultIcon(command.id);
}
4. Error State Handling
Missing Schema Fallback:
{#if schemaError}
<div class="missing-schema-state">
<h3>Schema Not Found</h3>
<p>The "{nodeType}" entity type doesn't have a schema definition.</p>
<div class="actions">
<button onclick={() => createSchemaInteractive(nodeType)}>
Create Schema
</button>
<button onclick={() => convertToTextNode(nodeId)}>
Convert to Text Node
</button>
</div>
</div>
{/if}
5. Visual Distinction
Styling Variables (CSS custom properties):
:root {
--custom-entity-accent: #6366f1; /* Indigo for custom entities */
--core-entity-accent: #10b981; /* Green for core types */
}
.custom-entity-node {
border-left-color: var(--custom-entity-accent);
}
.core-entity-node {
border-left-color: var(--core-entity-accent);
}
Badge/Label (entity header):
- Shows entity type name in small uppercase text
- Optional icon (emoji or SVG)
- Subtle background color
- Collapsible (user preference)
Acceptance Criteria
Functional Requirements
Visual Requirements
Performance Requirements
UX Requirements
Testing Plan
Unit Tests
describe('CustomEntityNode', () => {
it('renders with schema', async () => {
const { container } = render(CustomEntityNode, {
props: {
nodeId: 'test-node',
nodeType: 'invoice',
content: 'Invoice #001'
}
});
await waitFor(() => {
expect(container.querySelector('.entity-header')).toBeTruthy();
expect(container.querySelector('[data-entity-type="invoice"]')).toBeTruthy();
});
});
it('shows error state for missing schema', async () => {
// Mock schemaService to return error
vi.spyOn(schemaService, 'getSchema').mockRejectedValue(new Error('Not found'));
const { container } = render(CustomEntityNode, {
props: {
nodeId: 'test-node',
nodeType: 'nonexistent',
content: 'Test'
}
});
await waitFor(() => {
expect(container.textContent).toContain('Schema not found');
});
});
it('renders custom icon from schema', async () => {
const schema = {
properties: { icon: '💰' }
};
vi.spyOn(schemaService, 'getSchema').mockResolvedValue(schema);
const { container } = render(CustomEntityNode, {
props: {
nodeId: 'test-node',
nodeType: 'invoice',
content: 'Invoice'
}
});
await waitFor(() => {
expect(container.querySelector('.entity-icon').textContent).toBe('💰');
});
});
});
Integration Tests
describe('Custom entity rendering integration', () => {
it('creates custom entity and renders with properties', async () => {
// Create schema
await schemaService.createSchema('invoice', [
{ name: 'custom:amount', type: 'number' }
]);
// Create node
const node = await nodeService.createNode({
nodeType: 'invoice',
content: 'Invoice #001',
properties: { invoice: { amount: 1500 } }
});
// Render
const { container } = render(CustomEntityNode, {
props: { nodeId: node.id, nodeType: 'invoice', ...node }
});
// Verify property form appears
await waitFor(() => {
expect(container.querySelector('[data-testid="schema-property-form"]')).toBeTruthy();
});
});
});
Visual Regression Tests
- Screenshot comparison for custom entity rendering
- Icon display verification
- Error state appearance
- Loading state appearance
Dependencies
Performance Considerations
- Schema caching: Cache loaded schemas to avoid repeated fetches
- Component lazy loading: CustomEntityNode can be lazy-loaded per entity type
- Render optimization: Use Svelte reactivity best practices
Benchmark targets:
- First render: <50ms
- Schema load: <20ms (cached), <100ms (uncached)
- Property updates: <10ms
Migration Notes
No migration required - This is additive UI functionality.
Deployment notes:
Documentation Updates
Future Enhancements (Out of Scope)
- Custom component registration per entity type (specialized UI)
- Icon picker UI in schema editor
- Entity type templates library
- Visual schema editor
- Property reordering and drag-drop
Success Metrics
- Custom entities render as fast as core types (<50ms)
- Zero layout shifts during schema loading
- <2% user-reported rendering issues
- 90%+ user satisfaction with custom entity appearance
Labels: enhancement, ui, frontend, svelte, design-system
Priority: Medium (polish, not blocking)
Estimated effort: 6-8 hours
Parent Issue
Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation
Problem
While custom entity types can be created and used after implementing #446 and #447, the rendering experience needs polish to ensure custom entities look professional and are easy to work with.
Current state (after #446 + #447):
Desired state:
Solution
Polish the generic custom entity rendering experience with focus on:
Architecture
Component Hierarchy:
Fallback Chain:
Implementation Details
1. Custom Entity Node Component
File:
packages/desktop-app/src/lib/design/components/custom-entity-node.svelte2. Integration with Plugin Registry
Update
schema-plugin-loader.ts(from #447) to register CustomEntityNode:3. Icon System (Schema Metadata)
Allow schemas to specify custom icons:
{ "id": "invoice", "node_type": "schema", "properties": { "is_core": false, "icon": "💰", // Emoji or icon identifier "show_entity_header": true, // Show entity type badge "description": "Invoice tracking", "fields": [...] } }Update slash command dropdown to use schema icons:
4. Error State Handling
Missing Schema Fallback:
{#if schemaError} <div class="missing-schema-state"> <h3>Schema Not Found</h3> <p>The "{nodeType}" entity type doesn't have a schema definition.</p> <div class="actions"> <button onclick={() => createSchemaInteractive(nodeType)}> Create Schema </button> <button onclick={() => convertToTextNode(nodeId)}> Convert to Text Node </button> </div> </div> {/if}5. Visual Distinction
Styling Variables (CSS custom properties):
Badge/Label (entity header):
Acceptance Criteria
Functional Requirements
Visual Requirements
Performance Requirements
UX Requirements
Testing Plan
Unit Tests
Integration Tests
Visual Regression Tests
Dependencies
Performance Considerations
Benchmark targets:
Migration Notes
No migration required - This is additive UI functionality.
Deployment notes:
Documentation Updates
Future Enhancements (Out of Scope)
Success Metrics
Labels:
enhancement,ui,frontend,svelte,design-systemPriority: Medium (polish, not blocking)
Estimated effort: 6-8 hours