From c4652190ebcbe135cc073077e121ac4ced71cf9b Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 18 Jan 2026 23:45:43 +0100 Subject: [PATCH] feat: add three viewing modes for app specification (#566) * feat: add three viewing modes for app specification Introduces View, Edit, and Source modes for the spec page: - View: Clean read-only display with cards, badges, and accordions - Edit: Structured form-based editor for all spec fields - Source: Raw XML editor for advanced users Also adds @automaker/spec-parser shared package for XML parsing between server and client. * fix: address PR review feedback - Replace array index keys with stable UUIDs in array-field-editor, features-section, and roadmap-section components - Replace regex-based XML parsing with fast-xml-parser for robustness - Simplify renderContent logic in spec-view by removing dead code paths * fix: convert git+ssh URLs to https in package-lock.json * fix: address PR review feedback for spec visualiser - Remove unused RefreshCw import from spec-view.tsx - Add explicit parsedSpec check in renderContent for robustness - Hide save button in view mode since it's read-only - Remove GripVertical drag handles since drag-and-drop is not implemented - Rename Map imports to MapIcon to avoid shadowing global Map - Escape tagName in xml-utils.ts RegExp functions for safety - Add aria-label attributes for accessibility on mode tabs * fix: address additional PR review feedback - Fix Textarea controlled/uncontrolled warning with default value - Preserve IDs in useEffect sync to avoid unnecessary remounts - Consolidate lucide-react imports - Add JSDoc note about tag attributes limitation in xml-utils.ts - Remove redundant disabled prop from SpecModeTabs --- apps/ui/package.json | 1 + .../src/components/ui/xml-syntax-editor.tsx | 53 +--- apps/ui/src/components/views/spec-view.tsx | 85 +++++- .../edit-mode/array-field-editor.tsx | 106 +++++++ .../edit-mode/capabilities-section.tsx | 30 ++ .../components/edit-mode/features-section.tsx | 261 ++++++++++++++++++ .../spec-view/components/edit-mode/index.ts | 7 + .../edit-mode/optional-sections.tsx | 59 ++++ .../edit-mode/project-info-section.tsx | 51 ++++ .../components/edit-mode/roadmap-section.tsx | 195 +++++++++++++ .../edit-mode/tech-stack-section.tsx | 30 ++ .../views/spec-view/components/index.ts | 3 + .../spec-view/components/spec-edit-mode.tsx | 118 ++++++++ .../spec-view/components/spec-editor.tsx | 4 +- .../spec-view/components/spec-header.tsx | 43 +-- .../spec-view/components/spec-mode-tabs.tsx | 55 ++++ .../spec-view/components/spec-view-mode.tsx | 223 +++++++++++++++ .../components/views/spec-view/hooks/index.ts | 2 + .../views/spec-view/hooks/use-spec-parser.ts | 61 ++++ .../src/components/views/spec-view/types.ts | 3 + libs/spec-parser/package.json | 39 +++ libs/spec-parser/src/index.ts | 26 ++ libs/spec-parser/src/spec-to-xml.ts | 88 ++++++ libs/spec-parser/src/validate.ts | 143 ++++++++++ libs/spec-parser/src/xml-to-spec.ts | 232 ++++++++++++++++ libs/spec-parser/src/xml-utils.ts | 79 ++++++ libs/spec-parser/tsconfig.json | 9 + package-lock.json | 71 ++++- package.json | 2 +- 29 files changed, 1994 insertions(+), 85 deletions(-) create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/array-field-editor.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/index.ts create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/optional-sections.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/project-info-section.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/edit-mode/tech-stack-section.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/spec-edit-mode.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/spec-mode-tabs.tsx create mode 100644 apps/ui/src/components/views/spec-view/components/spec-view-mode.tsx create mode 100644 apps/ui/src/components/views/spec-view/hooks/use-spec-parser.ts create mode 100644 libs/spec-parser/package.json create mode 100644 libs/spec-parser/src/index.ts create mode 100644 libs/spec-parser/src/spec-to-xml.ts create mode 100644 libs/spec-parser/src/validate.ts create mode 100644 libs/spec-parser/src/xml-to-spec.ts create mode 100644 libs/spec-parser/src/xml-utils.ts create mode 100644 libs/spec-parser/tsconfig.json diff --git a/apps/ui/package.json b/apps/ui/package.json index 72755463..f0053d53 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@automaker/dependency-resolver": "1.0.0", + "@automaker/spec-parser": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", "@codemirror/language": "^6.12.1", diff --git a/apps/ui/src/components/ui/xml-syntax-editor.tsx b/apps/ui/src/components/ui/xml-syntax-editor.tsx index 8929d4a8..6f9aac33 100644 --- a/apps/ui/src/components/ui/xml-syntax-editor.tsx +++ b/apps/ui/src/components/ui/xml-syntax-editor.tsx @@ -1,9 +1,6 @@ import CodeMirror from '@uiw/react-codemirror'; import { xml } from '@codemirror/lang-xml'; import { EditorView } from '@codemirror/view'; -import { Extension } from '@codemirror/state'; -import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { tags as t } from '@lezer/highlight'; import { cn } from '@/lib/utils'; interface XmlSyntaxEditorProps { @@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps { 'data-testid'?: string; } -// Syntax highlighting that uses CSS variables from the app's theme system -// This automatically adapts to any theme (dark, light, dracula, nord, etc.) -const syntaxColors = HighlightStyle.define([ - // XML tags - use primary color - { tag: t.tagName, color: 'var(--primary)' }, - { tag: t.angleBracket, color: 'var(--muted-foreground)' }, - - // Attributes - { tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, - { tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, - - // Strings and content - { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, - { tag: t.content, color: 'var(--foreground)' }, - - // Comments - { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, - - // Special - { tag: t.processingInstruction, color: 'var(--muted-foreground)' }, - { tag: t.documentMeta, color: 'var(--muted-foreground)' }, -]); - -// Editor theme using CSS variables +// Simple editor theme - inherits text color from parent const editorTheme = EditorView.theme({ '&': { height: '100%', fontSize: '0.875rem', - fontFamily: 'ui-monospace, monospace', backgroundColor: 'transparent', - color: 'var(--foreground)', }, '.cm-scroller': { overflow: 'auto', - fontFamily: 'ui-monospace, monospace', }, '.cm-content': { padding: '1rem', minHeight: '100%', - caretColor: 'var(--primary)', - }, - '.cm-cursor, .cm-dropCursor': { - borderLeftColor: 'var(--primary)', - }, - '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { - backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', }, '.cm-activeLine': { backgroundColor: 'transparent', @@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({ '.cm-gutters': { display: 'none', }, - '.cm-placeholder': { - color: 'var(--muted-foreground)', - fontStyle: 'italic', - }, }); -// Combine all extensions -const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme]; - export function XmlSyntaxEditor({ value, onChange, @@ -94,16 +51,16 @@ export function XmlSyntaxEditor({ ('view'); + // Actions panel state (for tablet/mobile) const [showActionsPanel, setShowActionsPanel] = useState(false); @@ -21,7 +34,10 @@ export function SpecView() { const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading(); // Save state - const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave(); + const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave(); + + // Parse the spec XML + const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec); // Generation state and handlers const { @@ -70,8 +86,17 @@ export function SpecView() { handleSync, } = useSpecGeneration({ loadSpec }); - // Reset hasChanges when spec is reloaded - // (This is needed because loadSpec updates appSpec in the store) + // Handle mode change - if parse is invalid, force source mode + const handleModeChange = useCallback( + (newMode: SpecViewModeType) => { + if ((newMode === 'view' || newMode === 'edit') && !isParseValid) { + // Can't switch to view/edit if parse is invalid + return; + } + setMode(newMode); + }, + [isParseValid] + ); // No project selected if (!currentProject) { @@ -126,6 +151,28 @@ export function SpecView() { ); } + // Render content based on mode + const renderContent = () => { + // If the XML is invalid or spec is not parsed, we can only show the source editor. + // The tabs for other modes are disabled, but this is an extra safeguard. + if (!isParseValid || !parsedSpec) { + return ; + } + + switch (mode) { + case 'view': + return ; + case 'edit': + return ; + case 'source': + default: + return ; + } + }; + + const isProcessing = + isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing; + // Main view - spec exists return (
@@ -145,9 +192,33 @@ export function SpecView() { onSaveClick={saveSpec} showActionsPanel={showActionsPanel} onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)} + showSaveButton={mode !== 'view'} /> - + {/* Mode tabs and content container */} +
+ {/* Mode tabs bar - inside the content area, centered */} + {!isProcessing && ( +
+ + {/* Show parse error indicator - positioned to the right */} + {!isParseValid && parseErrors.length > 0 && ( + + XML has errors - fix in Source mode + + )} +
+ )} + + {/* Show parse error banner if in source mode with errors */} + {!isParseValid && parseErrors.length > 0 && mode === 'source' && ( +
+ XML Parse Errors: {parseErrors.join(', ')} +
+ )} + + {renderContent()} +
void; + placeholder?: string; + addLabel?: string; + emptyMessage?: string; +} + +interface ItemWithId { + id: string; + value: string; +} + +function generateId(): string { + return crypto.randomUUID(); +} + +export function ArrayFieldEditor({ + values, + onChange, + placeholder = 'Enter value...', + addLabel = 'Add Item', + emptyMessage = 'No items added yet.', +}: ArrayFieldEditorProps) { + // Track items with stable IDs + const [items, setItems] = useState(() => + values.map((value) => ({ id: generateId(), value })) + ); + + // Track if we're making an internal change to avoid sync loops + const isInternalChange = useRef(false); + + // Sync external values to internal items when values change externally + useEffect(() => { + if (isInternalChange.current) { + isInternalChange.current = false; + return; + } + + // External change - rebuild items with new IDs + setItems(values.map((value) => ({ id: generateId(), value }))); + }, [values]); + + const handleAdd = () => { + const newItems = [...items, { id: generateId(), value: '' }]; + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + const handleRemove = (id: string) => { + const newItems = items.filter((item) => item.id !== id); + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + const handleChange = (id: string, value: string) => { + const newItems = items.map((item) => (item.id === id ? { ...item, value } : item)); + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + return ( +
+ {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {items.map((item) => ( + +
+ handleChange(item.id, e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ))} +
+ )} + +
+ ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx new file mode 100644 index 00000000..cfec2d78 --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Lightbulb } from 'lucide-react'; +import { ArrayFieldEditor } from './array-field-editor'; + +interface CapabilitiesSectionProps { + capabilities: string[]; + onChange: (capabilities: string[]) => void; +} + +export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) { + return ( + + + + + Core Capabilities + + + + + + + ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx new file mode 100644 index 00000000..1cdbac2f --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -0,0 +1,261 @@ +import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ListChecks } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import type { SpecOutput } from '@automaker/spec-parser'; + +type Feature = SpecOutput['implemented_features'][number]; + +interface FeaturesSectionProps { + features: Feature[]; + onChange: (features: Feature[]) => void; +} + +interface FeatureWithId extends Feature { + _id: string; + _locationIds?: string[]; +} + +function generateId(): string { + return crypto.randomUUID(); +} + +function featureToInternal(feature: Feature): FeatureWithId { + return { + ...feature, + _id: generateId(), + _locationIds: feature.file_locations?.map(() => generateId()), + }; +} + +function internalToFeature(internal: FeatureWithId): Feature { + const { _id, _locationIds, ...feature } = internal; + return feature; +} + +interface FeatureCardProps { + feature: FeatureWithId; + index: number; + onChange: (feature: FeatureWithId) => void; + onRemove: () => void; +} + +function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleNameChange = (name: string) => { + onChange({ ...feature, name }); + }; + + const handleDescriptionChange = (description: string) => { + onChange({ ...feature, description }); + }; + + const handleAddLocation = () => { + const locations = feature.file_locations || []; + const locationIds = feature._locationIds || []; + onChange({ + ...feature, + file_locations: [...locations, ''], + _locationIds: [...locationIds, generateId()], + }); + }; + + const handleRemoveLocation = (locId: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const newLocations = feature.file_locations?.filter((_, i) => i !== idx); + const newLocationIds = locationIds.filter((id) => id !== locId); + onChange({ + ...feature, + file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined, + _locationIds: newLocationIds.length > 0 ? newLocationIds : undefined, + }); + }; + + const handleLocationChange = (locId: string, value: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const locations = [...(feature.file_locations || [])]; + locations[idx] = value; + onChange({ ...feature, file_locations: locations }); + }; + + return ( + + +
+ + + +
+ handleNameChange(e.target.value)} + placeholder="Feature name..." + className="font-medium" + /> +
+ + #{index + 1} + + +
+ +
+
+ +