Skip to content

Commit f5e8ab2

Browse files
authored
Merge pull request #467 from objectstack-ai/copilot/analyze-code-optimization
2 parents 6ec3016 + 65ef1c4 commit f5e8ab2

File tree

10 files changed

+816
-37
lines changed

10 files changed

+816
-37
lines changed

DESIGNER_UX_ANALYSIS.md

Lines changed: 522 additions & 0 deletions
Large diffs are not rendered by default.

ROADMAP.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,45 @@ The v2.0.7 spec introduces 70+ new UI types across 12 domains. This section maps
293293
- [x] Global search results page (beyond command palette)
294294
- [x] Recent items / favorites in sidebar
295295

296+
#### 2.8 Designer UX Enhancement (Feb 2026)
297+
**Target:** Enterprise-quality designer experience across all 5 designers
298+
299+
> 📄 See [DESIGNER_UX_ANALYSIS.md](./DESIGNER_UX_ANALYSIS.md) for full analysis
300+
301+
**Phase 1: Accessibility & Polish ✅ Complete**
302+
- [x] Add ARIA attributes (aria-label, role=toolbar/region/tablist/tab, aria-selected) across all 5 designers
303+
- [x] Add keyboard shortcuts (Delete to remove selected, Escape to deselect) to PageDesigner, DataModelDesigner, ProcessDesigner, ReportDesigner
304+
- [x] Improve empty states with guidance text across all designers
305+
- [x] Add zoom controls (−/+/fit) to PageDesigner canvas
306+
- [x] Improve property panels (PageDesigner label editing, ReportDesigner element details)
307+
- [x] Add ConnectionStatusIndicator component to CollaborationProvider
308+
- [x] Expand user color palette from 8 to 16 colors
309+
- [x] Add tab accessibility (role=tablist, role=tab, aria-selected) to ViewDesigner
310+
- [x] Replace emoji indicators (🔑) with text indicators (PK) in DataModelDesigner
311+
312+
**Phase 2: Interaction Layer (Next Sprint)**
313+
- [ ] Implement drag-and-drop for component/entity/node positioning using @dnd-kit
314+
- [ ] Implement undo/redo using command pattern with state history
315+
- [ ] Add confirmation dialogs for destructive delete actions
316+
- [ ] Implement edge creation UI in ProcessDesigner (click-to-connect nodes)
317+
- [ ] Add inline entity field editing in DataModelDesigner
318+
319+
**Phase 3: Advanced Features (Q2 2026)**
320+
- [ ] Full property editors with live preview for all designers
321+
- [ ] i18n integration for all hardcoded UI strings via resolveI18nLabel
322+
- [ ] Canvas pan/zoom with minimap for DataModelDesigner and ProcessDesigner
323+
- [ ] Auto-layout algorithms for entity and node positioning
324+
- [ ] Copy/paste support (Ctrl+C/V) across all designers
325+
- [ ] Multi-select and bulk operations
326+
- [ ] Responsive/collapsible panel layout
327+
328+
**Phase 4: Collaboration Integration (Q3 2026)**
329+
- [ ] Wire CollaborationProvider into each designer for real-time co-editing
330+
- [ ] Live cursor positions on shared canvases
331+
- [ ] Operation-based undo/redo synchronized across collaborators
332+
- [ ] Conflict resolution UI for concurrent edits
333+
- [ ] Version history browser with visual diff
334+
296335
**Q2 Milestone:**
297336
- **v1.0.0 Release (June 2026):** Full interactive experience — DnD, gestures, focus, animation, notifications, view enhancements
298337
- **Spec compliance: 86% → 96%**

packages/plugin-designer/src/CollaborationProvider.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
import React, { createContext, useContext, useCallback, useMemo, useEffect, useRef, useState } from 'react';
1010
import type { CollaborationConfig, CollaborationPresence, CollaborationOperation } from '@object-ui/types';
11+
import { clsx } from 'clsx';
12+
import { twMerge } from 'tailwind-merge';
13+
14+
function cn(...inputs: (string | undefined | false)[]) {
15+
return twMerge(clsx(inputs));
16+
}
1117

1218
export interface CollaborationContextValue {
1319
/** Active users in the session */
@@ -200,13 +206,43 @@ export function useCollaboration(): CollaborationContextValue | null {
200206
return useContext(CollabCtx);
201207
}
202208

209+
/**
210+
* Connection status indicator component.
211+
* Shows the current collaboration connection state with a colored dot and label.
212+
*/
213+
export function ConnectionStatusIndicator({ className }: { className?: string }) {
214+
const ctx = useContext(CollabCtx);
215+
if (!ctx) return null;
216+
217+
const { connectionState, users } = ctx;
218+
const stateConfig: Record<string, { color: string; label: string }> = {
219+
connected: { color: 'bg-green-500', label: 'Connected' },
220+
connecting: { color: 'bg-yellow-500 animate-pulse', label: 'Connecting…' },
221+
disconnected: { color: 'bg-gray-400', label: 'Disconnected' },
222+
error: { color: 'bg-red-500', label: 'Connection error' },
223+
};
224+
const { color, label } = stateConfig[connectionState] ?? stateConfig.disconnected;
225+
226+
return (
227+
<div className={cn('flex items-center gap-2 text-xs', className)} role="status" aria-live="polite" aria-label={`Collaboration: ${label}`}>
228+
<span className={`inline-block h-2 w-2 rounded-full ${color}`} />
229+
<span>{label}</span>
230+
{connectionState === 'connected' && users.length > 1 && (
231+
<span className="text-muted-foreground">({users.length} users)</span>
232+
)}
233+
</div>
234+
);
235+
}
236+
203237
/**
204238
* Generate a consistent color for a user ID.
205239
*/
206240
function generateColor(userId: string): string {
207241
const colors = [
208242
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
209243
'#8b5cf6', '#ec4899', '#06b6d4', '#f97316',
244+
'#14b8a6', '#f43f5e', '#a855f7', '#84cc16',
245+
'#0ea5e9', '#e879f9', '#fb923c', '#facc15',
210246
];
211247
let hash = 0;
212248
for (let i = 0; i < userId.length; i++) {

packages/plugin-designer/src/DataModelDesigner.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import React, { useState, useCallback } from 'react';
9+
import React, { useState, useCallback, useEffect, useRef } from 'react';
1010
import type { DataModelEntity, DataModelField, DataModelRelationship, DesignerCanvasConfig } from '@object-ui/types';
1111
import { Database, Plus, Trash2, Link2 } from 'lucide-react';
1212
import { clsx } from 'clsx';
@@ -51,6 +51,7 @@ export function DataModelDesigner({
5151
onRelationshipsChange,
5252
className,
5353
}: DataModelDesignerProps) {
54+
const containerRef = useRef<HTMLDivElement>(null);
5455
const [entities, setEntities] = useState<DataModelEntity[]>(initialEntities);
5556
const [relationships, setRelationships] = useState<DataModelRelationship[]>(initialRelationships);
5657
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
@@ -90,23 +91,43 @@ export function DataModelDesigner({
9091
[entities, relationships, selectedEntityId, readOnly, onEntitiesChange, onRelationshipsChange],
9192
);
9293

94+
useEffect(() => {
95+
const el = containerRef.current;
96+
if (!el) return;
97+
const handleKeyDown = (e: KeyboardEvent) => {
98+
const tag = (e.target as HTMLElement).tagName;
99+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
100+
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEntityId) {
101+
e.preventDefault();
102+
handleDeleteEntity(selectedEntityId);
103+
} else if (e.key === 'Escape') {
104+
setSelectedEntityId(null);
105+
}
106+
};
107+
el.addEventListener('keydown', handleKeyDown);
108+
return () => el.removeEventListener('keydown', handleKeyDown);
109+
}, [selectedEntityId, handleDeleteEntity]);
110+
93111
return (
94-
<div className={cn('flex h-full w-full border rounded-lg overflow-hidden bg-background', className)}>
112+
<div ref={containerRef} tabIndex={0} className={cn('flex h-full w-full border rounded-lg overflow-hidden bg-background', className)}>
95113
{/* Toolbar */}
96114
<div className="flex flex-col w-full">
97-
<div className="flex items-center gap-2 p-2 border-b bg-muted/20">
115+
<div role="toolbar" className="flex items-center gap-2 p-2 border-b bg-muted/20">
98116
<Database className="h-4 w-4" />
99117
<span className="font-medium text-sm">Data Model Designer</span>
100118
<div className="flex-1" />
101119
{!readOnly && (
102120
<>
103121
<button
104122
onClick={handleAddEntity}
123+
aria-label="Add Entity"
105124
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-primary text-primary-foreground hover:bg-primary/90"
106125
>
107126
<Plus className="h-3 w-3" /> Add Entity
108127
</button>
109128
<button
129+
aria-label="Add Relationship"
130+
title="Add Relationship (coming soon)"
110131
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-secondary text-secondary-foreground hover:bg-secondary/80"
111132
>
112133
<Link2 className="h-3 w-3" /> Add Relationship
@@ -116,7 +137,7 @@ export function DataModelDesigner({
116137
</div>
117138

118139
{/* Canvas */}
119-
<div className="flex-1 overflow-auto bg-muted/10 p-4">
140+
<div role="region" aria-label="Canvas" className="flex-1 overflow-auto bg-muted/10 p-4">
120141
<div
121142
className="relative"
122143
style={{
@@ -166,9 +187,16 @@ export function DataModelDesigner({
166187
</svg>
167188

168189
{/* Entity cards */}
190+
{entities.length === 0 && (
191+
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
192+
No entities in the model. Click &apos;Add Entity&apos; to create your first entity.
193+
</div>
194+
)}
169195
{entities.map((entity) => (
170196
<div
171197
key={entity.id}
198+
role="group"
199+
aria-label={entity.label}
172200
className={cn(
173201
'absolute rounded-lg border-2 bg-background shadow-sm w-60 select-none',
174202
selectedEntityId === entity.id
@@ -194,6 +222,7 @@ export function DataModelDesigner({
194222
e.stopPropagation();
195223
handleDeleteEntity(entity.id);
196224
}}
225+
aria-label={`Delete ${entity.label}`}
197226
className="ml-auto p-0.5 rounded hover:bg-destructive/20"
198227
>
199228
<Trash2 className="h-3 w-3 text-destructive" />
@@ -208,7 +237,7 @@ export function DataModelDesigner({
208237
className="flex items-center gap-2 py-1 text-xs"
209238
>
210239
<span className={cn('font-mono', field.primaryKey && 'font-bold text-primary')}>
211-
{field.primaryKey ? '🔑 ' : ''}{field.name}
240+
{field.primaryKey && <span className="text-[0.65rem] font-semibold text-primary mr-0.5">PK</span>}{field.name}
212241
</span>
213242
<span className="text-muted-foreground ml-auto">{field.type}</span>
214243
{field.required && <span className="text-destructive">*</span>}

packages/plugin-designer/src/PageDesigner.tsx

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import React, { useState, useCallback, useMemo } from 'react';
9+
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
1010
import type {
1111
DesignerComponent,
1212
DesignerCanvasConfig,
1313
DesignerPaletteCategory,
1414
DesignerPaletteItem,
1515
} from '@object-ui/types';
16-
import { GripVertical, Undo2, Redo2, Eye, Layers, Plus, Trash2 } from 'lucide-react';
16+
import { GripVertical, Undo2, Redo2, Eye, Layers, Plus, Minus, Maximize2, Trash2 } from 'lucide-react';
1717
import { clsx } from 'clsx';
1818
import { twMerge } from 'tailwind-merge';
1919

@@ -42,6 +42,10 @@ export interface PageDesignerProps {
4242
className?: string;
4343
}
4444

45+
const ZOOM_MIN = 0.25;
46+
const ZOOM_MAX = 3;
47+
const ZOOM_STEP = 0.1;
48+
4549
/**
4650
* Drag-and-drop page designer component.
4751
* Allows visual composition of UI components on a canvas.
@@ -58,7 +62,8 @@ export function PageDesigner({
5862
}: PageDesignerProps) {
5963
const [components, setComponents] = useState<DesignerComponent[]>(initialComponents);
6064
const [selectedId, setSelectedId] = useState<string | null>(null);
61-
const [zoom, _setZoom] = useState(canvas.zoom ?? 1);
65+
const [zoom, setZoom] = useState(canvas.zoom ?? 1);
66+
const containerRef = useRef<HTMLDivElement>(null);
6267

6368
const selectedComponent = useMemo(
6469
() => components.find((c) => c.id === selectedId),
@@ -94,11 +99,38 @@ export function PageDesigner({
9499
[components, selectedId, readOnly, onChange],
95100
);
96101

102+
const handleUpdateLabel = useCallback(
103+
(id: string, label: string) => {
104+
if (readOnly) return;
105+
const updated = components.map((c) => (c.id === id ? { ...c, label } : c));
106+
setComponents(updated);
107+
onChange?.(updated);
108+
},
109+
[components, readOnly, onChange],
110+
);
111+
112+
useEffect(() => {
113+
const el = containerRef.current;
114+
if (!el) return;
115+
const handleKeyDown = (e: KeyboardEvent) => {
116+
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) {
117+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) return;
118+
e.preventDefault();
119+
handleDeleteComponent(selectedId);
120+
}
121+
if (e.key === 'Escape') {
122+
setSelectedId(null);
123+
}
124+
};
125+
el.addEventListener('keydown', handleKeyDown);
126+
return () => el.removeEventListener('keydown', handleKeyDown);
127+
}, [selectedId, handleDeleteComponent]);
128+
97129
return (
98-
<div className={cn('flex h-full w-full border rounded-lg overflow-hidden bg-background', className)}>
130+
<div ref={containerRef} tabIndex={0} className={cn('flex h-full w-full border rounded-lg overflow-hidden bg-background', className)}>
99131
{/* Left Panel - Component Palette */}
100132
{!readOnly && (
101-
<div className="w-60 border-r bg-muted/30 flex flex-col">
133+
<div className="w-60 border-r bg-muted/30 flex flex-col" role="region" aria-label="Component palette">
102134
<div className="p-3 border-b font-medium text-sm">Components</div>
103135
<div className="flex-1 overflow-y-auto p-2">
104136
{palette.map((category) => (
@@ -125,29 +157,52 @@ export function PageDesigner({
125157
{/* Center - Canvas */}
126158
<div className="flex-1 flex flex-col">
127159
{/* Toolbar */}
128-
<div className="flex items-center gap-2 p-2 border-b bg-muted/20">
160+
<div className="flex items-center gap-2 p-2 border-b bg-muted/20" role="toolbar" aria-label="Designer toolbar">
129161
{undoRedo && !readOnly && (
130162
<>
131-
<button className="p-1.5 rounded hover:bg-accent" title="Undo">
163+
<button className="p-1.5 rounded hover:bg-accent" title="Undo" aria-label="Undo">
132164
<Undo2 className="h-4 w-4" />
133165
</button>
134-
<button className="p-1.5 rounded hover:bg-accent" title="Redo">
166+
<button className="p-1.5 rounded hover:bg-accent" title="Redo" aria-label="Redo">
135167
<Redo2 className="h-4 w-4" />
136168
</button>
137169
<div className="w-px h-5 bg-border mx-1" />
138170
</>
139171
)}
140-
<button className="p-1.5 rounded hover:bg-accent" title="Preview">
172+
<button className="p-1.5 rounded hover:bg-accent" title="Preview" aria-label="Preview">
141173
<Eye className="h-4 w-4" />
142174
</button>
143175
<div className="flex-1" />
144-
<span className="text-xs text-muted-foreground">
145-
{Math.round(zoom * 100)}%
146-
</span>
176+
<div className="flex items-center gap-1">
177+
<button
178+
className="p-1 rounded hover:bg-accent text-xs"
179+
aria-label="Zoom out"
180+
onClick={() => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)))}
181+
>
182+
<Minus className="h-3 w-3" />
183+
</button>
184+
<span className="text-xs text-muted-foreground w-10 text-center">
185+
{Math.round(zoom * 100)}%
186+
</span>
187+
<button
188+
className="p-1 rounded hover:bg-accent text-xs"
189+
aria-label="Zoom in"
190+
onClick={() => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)))}
191+
>
192+
<Plus className="h-3 w-3" />
193+
</button>
194+
<button
195+
className="p-1 rounded hover:bg-accent text-xs"
196+
aria-label="Reset zoom"
197+
onClick={() => setZoom(1)}
198+
>
199+
<Maximize2 className="h-3 w-3" />
200+
</button>
201+
</div>
147202
</div>
148203

149204
{/* Canvas Area */}
150-
<div className="flex-1 overflow-auto bg-muted/10 p-4">
205+
<div className="flex-1 overflow-auto bg-muted/10 p-4" role="region" aria-label="Design canvas">
151206
<div
152207
className="relative bg-background border rounded shadow-sm mx-auto"
153208
style={{
@@ -188,6 +243,7 @@ export function PageDesigner({
188243
handleDeleteComponent(comp.id);
189244
}}
190245
className="ml-auto p-0.5 rounded hover:bg-destructive/10"
246+
aria-label={`Delete ${comp.label ?? comp.type}`}
191247
>
192248
<Trash2 className="h-3 w-3 text-destructive" />
193249
</button>
@@ -204,15 +260,15 @@ export function PageDesigner({
204260

205261
{/* Right Panel - Component Tree / Properties */}
206262
{showComponentTree && (
207-
<div className="w-60 border-l bg-muted/30 flex flex-col">
263+
<div className="w-60 border-l bg-muted/30 flex flex-col" role="region" aria-label="Component tree">
208264
<div className="p-3 border-b font-medium text-sm flex items-center gap-2">
209265
<Layers className="h-4 w-4" />
210266
Component Tree
211267
</div>
212268
<div className="flex-1 overflow-y-auto p-2">
213269
{components.length === 0 ? (
214270
<div className="text-xs text-muted-foreground text-center py-4">
215-
No components added yet
271+
No components added yet. Click a component in the palette to add it to the canvas.
216272
</div>
217273
) : (
218274
components.map((comp) => (
@@ -235,6 +291,15 @@ export function PageDesigner({
235291
<div className="text-xs text-muted-foreground space-y-1">
236292
<div>Type: {selectedComponent.type}</div>
237293
<div>ID: {selectedComponent.id}</div>
294+
<label className="flex items-center gap-1">
295+
Label:
296+
<input
297+
type="text"
298+
value={selectedComponent.label ?? ''}
299+
onChange={(e) => handleUpdateLabel(selectedComponent.id, e.target.value)}
300+
className="flex-1 px-1 py-0.5 border rounded text-xs bg-background"
301+
/>
302+
</label>
238303
<div>
239304
Position: {selectedComponent.position.x}, {selectedComponent.position.y}
240305
</div>

0 commit comments

Comments
 (0)