From bf15c7e8ef212dd0653a7c5847182360f3542682 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 18:17:28 +0100 Subject: [PATCH 1/4] 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. --- apps/ui/package.json | 1 + .../src/components/ui/xml-syntax-editor.tsx | 53 +---- apps/ui/src/components/views/spec-view.tsx | 97 +++++++- .../edit-mode/array-field-editor.tsx | 72 ++++++ .../edit-mode/capabilities-section.tsx | 30 +++ .../components/edit-mode/features-section.tsx | 200 ++++++++++++++++ .../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 | 147 ++++++++++++ .../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 | 48 ++++ .../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 | 38 +++ 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 | 183 ++++++++++++++ libs/spec-parser/src/xml-utils.ts | 64 +++++ libs/spec-parser/tsconfig.json | 9 + package-lock.json | 40 +++- package.json | 2 +- 29 files changed, 1760 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 727554635..f0053d53e 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 8929d4a82..6f9aac333 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,35 @@ export function SpecView() { ); } + // Render content based on mode + const renderContent = () => { + // If parse failed and we're not in source mode, force source mode + const effectiveMode = !isParseValid && mode !== 'source' ? 'source' : mode; + + switch (effectiveMode) { + case 'view': + if (parsedSpec) { + return ; + } + // Fallback to source if parsing fails + return ; + + case 'edit': + if (parsedSpec) { + return ; + } + // Fallback to source if parsing fails + return ; + + case 'source': + default: + return ; + } + }; + + const isProcessing = + isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing; + // Main view - spec exists return (
@@ -145,9 +199,38 @@ export function SpecView() { onSaveClick={saveSpec} showActionsPanel={showActionsPanel} onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)} + showSaveButton={true} /> - + {/* 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; +} + +export function ArrayFieldEditor({ + values, + onChange, + placeholder = 'Enter value...', + addLabel = 'Add Item', + emptyMessage = 'No items added yet.', +}: ArrayFieldEditorProps) { + const handleAdd = () => { + onChange([...values, '']); + }; + + const handleRemove = (index: number) => { + const newValues = values.filter((_, i) => i !== index); + onChange(newValues); + }; + + const handleChange = (index: number, value: string) => { + const newValues = [...values]; + newValues[index] = value; + onChange(newValues); + }; + + return ( +
+ {values.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {values.map((value, index) => ( + +
+ + handleChange(index, 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 000000000..cfec2d785 --- /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 000000000..d2ad5f2b5 --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -0,0 +1,200 @@ +import { Plus, X, GripVertical, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react'; +import { useState } 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 FeatureCardProps { + feature: Feature; + index: number; + onChange: (feature: Feature) => 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 || []; + onChange({ ...feature, file_locations: [...locations, ''] }); + }; + + const handleRemoveLocation = (locIndex: number) => { + const locations = feature.file_locations?.filter((_, i) => i !== locIndex); + onChange({ + ...feature, + file_locations: locations && locations.length > 0 ? locations : undefined, + }); + }; + + const handleLocationChange = (locIndex: number, value: string) => { + const locations = [...(feature.file_locations || [])]; + locations[locIndex] = value; + onChange({ ...feature, file_locations: locations }); + }; + + return ( + + +
+ + + + +
+ handleNameChange(e.target.value)} + placeholder="Feature name..." + className="font-medium" + /> +
+ + #{index + 1} + + +
+ +
+
+ +