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
This commit is contained in:
Stefan de Vogelaere
2026-01-18 23:45:43 +01:00
committed by GitHub
parent af95dae73a
commit c4652190eb
29 changed files with 1994 additions and 85 deletions

View File

@@ -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",

View File

@@ -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({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}

View File

@@ -1,19 +1,32 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Spinner } from '@/components/ui/spinner';
// Extracted hooks
import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks';
import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks';
// Extracted components
import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components';
import {
SpecHeader,
SpecEditor,
SpecEmptyState,
SpecViewMode,
SpecEditMode,
SpecModeTabs,
} from './spec-view/components';
// Extracted dialogs
import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
// Types
import type { SpecViewMode as SpecViewModeType } from './spec-view/types';
export function SpecView() {
const { currentProject, appSpec } = useAppStore();
// View mode state - default to 'view'
const [mode, setMode] = useState<SpecViewModeType>('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 <SpecEditor value={appSpec} onChange={handleChange} />;
}
switch (mode) {
case 'view':
return <SpecViewMode spec={parsedSpec} />;
case 'edit':
return <SpecEditMode spec={parsedSpec} onChange={handleChange} />;
case 'source':
default:
return <SpecEditor value={appSpec} onChange={handleChange} />;
}
};
const isProcessing =
isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing;
// Main view - spec exists
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
@@ -145,9 +192,33 @@ export function SpecView() {
onSaveClick={saveSpec}
showActionsPanel={showActionsPanel}
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
showSaveButton={mode !== 'view'}
/>
<SpecEditor value={appSpec} onChange={handleChange} />
{/* Mode tabs and content container */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mode tabs bar - inside the content area, centered */}
{!isProcessing && (
<div className="flex items-center justify-center px-4 py-2 border-b border-border bg-muted/30 relative">
<SpecModeTabs mode={mode} onModeChange={handleModeChange} isParseValid={isParseValid} />
{/* Show parse error indicator - positioned to the right */}
{!isParseValid && parseErrors.length > 0 && (
<span className="absolute right-4 text-xs text-destructive">
XML has errors - fix in Source mode
</span>
)}
</div>
)}
{/* Show parse error banner if in source mode with errors */}
{!isParseValid && parseErrors.length > 0 && mode === 'source' && (
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-sm text-destructive">
<span className="font-medium">XML Parse Errors:</span> {parseErrors.join(', ')}
</div>
)}
{renderContent()}
</div>
<RegenerateSpecDialog
open={showRegenerateDialog}

View File

@@ -0,0 +1,106 @@
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { useRef, useState, useEffect } from 'react';
interface ArrayFieldEditorProps {
values: string[];
onChange: (values: string[]) => 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<ItemWithId[]>(() =>
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 (
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{emptyMessage}</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<Card key={item.id} className="p-2">
<div className="flex items-center gap-2">
<Input
value={item.value}
onChange={(e) => handleChange(item.id, e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(item.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={capabilities}
onChange={onChange}
placeholder="e.g., User authentication, Data visualization..."
addLabel="Add Capability"
emptyMessage="No capabilities defined. Add your core features."
/>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card className="border-border">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-1 h-auto">
{isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</CollapsibleTrigger>
<div className="flex-1 min-w-0">
<Input
value={feature.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Feature name..."
className="font-medium"
/>
</div>
<Badge variant="outline" className="shrink-0">
#{index + 1}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-4 border-t border-border pt-3 ml-10">
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={feature.description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddLocation}
className="gap-1 h-7"
>
<Plus className="w-3 h-3" />
Add
</Button>
</div>
{(feature.file_locations || []).length === 0 ? (
<p className="text-sm text-muted-foreground">No file locations specified.</p>
) : (
<div className="space-y-2">
{(feature.file_locations || []).map((location, idx) => {
const locId = feature._locationIds?.[idx] || `fallback-${idx}`;
return (
<div key={locId} className="flex items-center gap-2">
<Input
value={location}
onChange={(e) => handleLocationChange(locId, e.target.value)}
placeholder="e.g., src/components/feature.tsx"
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveLocation(locId)}
className="shrink-0 text-muted-foreground hover:text-destructive h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export function FeaturesSection({ features, onChange }: FeaturesSectionProps) {
// Track features with stable IDs
const [items, setItems] = useState<FeatureWithId[]>(() => features.map(featureToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external features to internal items when features change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems(features.map(featureToInternal));
}, [features]);
const handleAdd = () => {
const newItems = [...items, featureToInternal({ name: '', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleFeatureChange = (id: string, feature: FeatureWithId) => {
const newItems = items.map((item) => (item._id === id ? feature : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{items.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No features added yet. Click below to add implemented features.
</p>
) : (
<div className="space-y-2">
{items.map((feature, index) => (
<FeatureCard
key={feature._id}
feature={feature}
index={index}
onChange={(f) => handleFeatureChange(feature._id, f)}
onRemove={() => handleRemove(feature._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Feature
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { ArrayFieldEditor } from './array-field-editor';
export { ProjectInfoSection } from './project-info-section';
export { TechStackSection } from './tech-stack-section';
export { CapabilitiesSection } from './capabilities-section';
export { FeaturesSection } from './features-section';
export { RoadmapSection } from './roadmap-section';
export { RequirementsSection, GuidelinesSection } from './optional-sections';

View File

@@ -0,0 +1,59 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollText, Wrench } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface RequirementsSectionProps {
requirements: string[];
onChange: (requirements: string[]) => void;
}
export function RequirementsSection({ requirements, onChange }: RequirementsSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={requirements}
onChange={onChange}
placeholder="e.g., Node.js >= 18, Docker required..."
addLabel="Add Requirement"
emptyMessage="No additional requirements specified."
/>
</CardContent>
</Card>
);
}
interface GuidelinesSectionProps {
guidelines: string[];
onChange: (guidelines: string[]) => void;
}
export function GuidelinesSection({ guidelines, onChange }: GuidelinesSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={guidelines}
onChange={onChange}
placeholder="e.g., Follow TypeScript strict mode, use ESLint..."
addLabel="Add Guideline"
emptyMessage="No development guidelines specified."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { FileText } from 'lucide-react';
interface ProjectInfoSectionProps {
projectName: string;
overview: string;
onProjectNameChange: (value: string) => void;
onOverviewChange: (value: string) => void;
}
export function ProjectInfoSection({
projectName,
overview,
onProjectNameChange,
onOverviewChange,
}: ProjectInfoSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="w-5 h-5 text-primary" />
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={projectName}
onChange={(e) => onProjectNameChange(e.target.value)}
placeholder="Enter project name..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="overview">Overview</Label>
<Textarea
id="overview"
value={overview}
onChange={(e) => onOverviewChange(e.target.value)}
placeholder="Describe what this project does, its purpose, and key goals..."
rows={5}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,195 @@
import { Plus, X, Map as MapIcon } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SpecOutput } from '@automaker/spec-parser';
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
interface PhaseWithId extends RoadmapPhase {
_id: string;
}
function generateId(): string {
return crypto.randomUUID();
}
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
return { ...phase, _id: generateId() };
}
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
const { _id, ...phase } = internal;
return phase;
}
interface RoadmapSectionProps {
phases: RoadmapPhase[];
onChange: (phases: RoadmapPhase[]) => void;
}
interface PhaseCardProps {
phase: PhaseWithId;
onChange: (phase: PhaseWithId) => void;
onRemove: () => void;
}
function PhaseCard({ phase, onChange, onRemove }: PhaseCardProps) {
const handlePhaseNameChange = (name: string) => {
onChange({ ...phase, phase: name });
};
const handleStatusChange = (status: PhaseStatus) => {
onChange({ ...phase, status });
};
const handleDescriptionChange = (description: string) => {
onChange({ ...phase, description });
};
return (
<Card className="border-border">
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Label className="sr-only">Phase Name</Label>
<Input
value={phase.phase}
onChange={(e) => handlePhaseNameChange(e.target.value)}
placeholder="Phase name..."
/>
</div>
<div className="w-full sm:w-40">
<Label className="sr-only">Status</Label>
<Select value={phase.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="sr-only">Description</Label>
<Textarea
value={phase.description ?? ''}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this phase involves..."
rows={2}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
export function RoadmapSection({ phases, onChange }: RoadmapSectionProps) {
// Track phases with stable IDs
const [items, setItems] = useState<PhaseWithId[]>(() => phases.map(phaseToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external phases to internal items when phases change externally
// Preserve existing IDs where possible to avoid unnecessary remounts
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems((currentItems) => {
return phases.map((phase, index) => {
// Try to find existing item by index (positional matching)
const existingItem = currentItems[index];
if (existingItem) {
// Reuse the existing ID, update the phase data
return { ...phase, _id: existingItem._id };
}
// New phase - generate new ID
return phaseToInternal(phase);
});
});
}, [phases]);
const handleAdd = () => {
const newItems = [...items, phaseToInternal({ phase: '', status: 'pending', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handlePhaseChange = (id: string, phase: PhaseWithId) => {
const newItems = items.map((item) => (item._id === id ? phase : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No roadmap phases defined. Add phases to track implementation progress.
</p>
) : (
<div className="space-y-2">
{items.map((phase) => (
<PhaseCard
key={phase._id}
phase={phase}
onChange={(p) => handlePhaseChange(phase._id, p)}
onRemove={() => handleRemove(phase._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Phase
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Cpu } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface TechStackSectionProps {
technologies: string[];
onChange: (technologies: string[]) => void;
}
export function TechStackSection({ technologies, onChange }: TechStackSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={technologies}
onChange={onChange}
placeholder="e.g., React, TypeScript, Node.js..."
addLabel="Add Technology"
emptyMessage="No technologies added. Add your tech stack."
/>
</CardContent>
</Card>
);
}

View File

@@ -1,3 +1,6 @@
export { SpecHeader } from './spec-header';
export { SpecEditor } from './spec-editor';
export { SpecEmptyState } from './spec-empty-state';
export { SpecModeTabs } from './spec-mode-tabs';
export { SpecViewMode } from './spec-view-mode';
export { SpecEditMode } from './spec-edit-mode';

View File

@@ -0,0 +1,118 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { SpecOutput } from '@automaker/spec-parser';
import { specToXml } from '@automaker/spec-parser';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
ProjectInfoSection,
TechStackSection,
CapabilitiesSection,
FeaturesSection,
RoadmapSection,
RequirementsSection,
GuidelinesSection,
} from './edit-mode';
interface SpecEditModeProps {
spec: SpecOutput;
onChange: (xmlContent: string) => void;
}
export function SpecEditMode({ spec, onChange }: SpecEditModeProps) {
// Local state for form editing
const [formData, setFormData] = useState<SpecOutput>(spec);
// Track the last spec we synced FROM to detect external changes
const lastExternalSpecRef = useRef<string>(JSON.stringify(spec));
// Flag to prevent re-syncing when we caused the change
const isInternalChangeRef = useRef(false);
// Reset form only when spec changes externally (e.g., after save, sync, or regenerate)
useEffect(() => {
const specJson = JSON.stringify(spec);
// If we caused this change (internal), just update the ref and skip reset
if (isInternalChangeRef.current) {
lastExternalSpecRef.current = specJson;
isInternalChangeRef.current = false;
return;
}
// External change - reset form data
if (specJson !== lastExternalSpecRef.current) {
lastExternalSpecRef.current = specJson;
setFormData(spec);
}
}, [spec]);
// Update a field and notify parent
const updateField = useCallback(
<K extends keyof SpecOutput>(field: K, value: SpecOutput[K]) => {
setFormData((prev) => {
const newData = { ...prev, [field]: value };
// Mark as internal change before notifying parent
isInternalChangeRef.current = true;
const xmlContent = specToXml(newData);
onChange(xmlContent);
return newData;
});
},
[onChange]
);
return (
<ScrollArea className="flex-1">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Information */}
<ProjectInfoSection
projectName={formData.project_name}
overview={formData.overview}
onProjectNameChange={(value) => updateField('project_name', value)}
onOverviewChange={(value) => updateField('overview', value)}
/>
{/* Technology Stack */}
<TechStackSection
technologies={formData.technology_stack}
onChange={(value) => updateField('technology_stack', value)}
/>
{/* Core Capabilities */}
<CapabilitiesSection
capabilities={formData.core_capabilities}
onChange={(value) => updateField('core_capabilities', value)}
/>
{/* Implemented Features */}
<FeaturesSection
features={formData.implemented_features}
onChange={(value) => updateField('implemented_features', value)}
/>
{/* Additional Requirements (Optional) */}
<RequirementsSection
requirements={formData.additional_requirements || []}
onChange={(value) =>
updateField('additional_requirements', value.length > 0 ? value : undefined)
}
/>
{/* Development Guidelines (Optional) */}
<GuidelinesSection
guidelines={formData.development_guidelines || []}
onChange={(value) =>
updateField('development_guidelines', value.length > 0 ? value : undefined)
}
/>
{/* Implementation Roadmap (Optional) */}
<RoadmapSection
phases={formData.implementation_roadmap || []}
onChange={(value) =>
updateField('implementation_roadmap', value.length > 0 ? value : undefined)
}
/>
</div>
</ScrollArea>
);
}

View File

@@ -8,8 +8,8 @@ interface SpecEditorProps {
export function SpecEditor({ value, onChange }: SpecEditorProps) {
return (
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full">
<div className="flex-1 p-4 overflow-hidden min-h-0">
<Card className="h-full overflow-hidden">
<XmlSyntaxEditor
value={value}
onChange={onChange}

View File

@@ -23,6 +23,8 @@ interface SpecHeaderProps {
onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
// Mode-related props for save button visibility
showSaveButton: boolean;
}
export function SpecHeader({
@@ -41,6 +43,7 @@ export function SpecHeader({
onSaveClick,
showActionsPanel,
onToggleActionsPanel,
showSaveButton,
}: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
@@ -133,15 +136,17 @@ export function SpecHeader({
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
{showSaveButton && (
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
</div>
)}
{/* Tablet/Mobile: show trigger for actions panel */}
@@ -212,15 +217,17 @@ export function SpecHeader({
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
{showSaveButton && (
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
</>
)}
</HeaderActionsPanel>

View File

@@ -0,0 +1,55 @@
import { Eye, Edit3, Code } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { SpecViewMode } from '../types';
interface SpecModeTabsProps {
mode: SpecViewMode;
onModeChange: (mode: SpecViewMode) => void;
isParseValid: boolean;
disabled?: boolean;
}
export function SpecModeTabs({
mode,
onModeChange,
isParseValid,
disabled = false,
}: SpecModeTabsProps) {
const handleValueChange = (value: string) => {
onModeChange(value as SpecViewMode);
};
return (
<Tabs value={mode} onValueChange={handleValueChange}>
<TabsList>
<TabsTrigger
value="view"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable View mode' : 'View formatted spec'}
aria-label="View"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">View</span>
</TabsTrigger>
<TabsTrigger
value="edit"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable Edit mode' : 'Edit spec with form'}
aria-label="Edit"
>
<Edit3 className="w-4 h-4" />
<span className="hidden sm:inline">Edit</span>
</TabsTrigger>
<TabsTrigger
value="source"
disabled={disabled}
title="Edit raw XML source"
aria-label="Source"
>
<Code className="w-4 h-4" />
<span className="hidden sm:inline">Source</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}

View File

@@ -0,0 +1,223 @@
import type { SpecOutput } from '@automaker/spec-parser';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
CheckCircle2,
Circle,
Clock,
Cpu,
FileCode2,
FolderOpen,
Lightbulb,
ListChecks,
Map as MapIcon,
ScrollText,
Wrench,
} from 'lucide-react';
interface SpecViewModeProps {
spec: SpecOutput;
}
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'pending' }) {
const variants = {
completed: { variant: 'success' as const, icon: CheckCircle2, label: 'Completed' },
in_progress: { variant: 'warning' as const, icon: Clock, label: 'In Progress' },
pending: { variant: 'muted' as const, icon: Circle, label: 'Pending' },
};
const { variant, icon: Icon, label } = variants[status];
return (
<Badge variant={variant} className="gap-1">
<Icon className="w-3 h-3" />
{label}
</Badge>
);
}
export function SpecViewMode({ spec }: SpecViewModeProps) {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Header */}
<div className="space-y-3">
<h1 className="text-3xl font-bold tracking-tight">{spec.project_name}</h1>
<p className="text-muted-foreground text-lg leading-relaxed">{spec.overview}</p>
</div>
{/* Technology Stack */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{spec.technology_stack.map((tech, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{tech}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Core Capabilities */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.core_capabilities.map((capability, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 mt-1 shrink-0" />
<span>{capability}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Implemented Features */}
{spec.implemented_features.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{spec.implemented_features.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Accordion type="multiple" className="w-full">
{spec.implemented_features.map((feature, index) => (
<AccordionItem key={index} value={`feature-${index}`}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2 text-left">
<FileCode2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium">{feature.name}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pl-6">
<p className="text-muted-foreground">{feature.description}</p>
{feature.file_locations && feature.file_locations.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
{feature.file_locations.map((loc, locIndex) => (
<li
key={locIndex}
className="font-mono text-xs bg-muted px-2 py-1 rounded"
>
{loc}
</li>
))}
</ul>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
)}
{/* Additional Requirements */}
{spec.additional_requirements && spec.additional_requirements.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.additional_requirements.map((req, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{req}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Development Guidelines */}
{spec.development_guidelines && spec.development_guidelines.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.development_guidelines.map((guideline, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{guideline}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Implementation Roadmap */}
{spec.implementation_roadmap && spec.implementation_roadmap.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{spec.implementation_roadmap.map((phase, index) => (
<div
key={index}
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-4 p-3 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-2 sm:w-48 shrink-0">
<StatusBadge status={phase.status} />
</div>
<div className="flex-1">
<p className="font-medium">{phase.phase}</p>
<p className="text-sm text-muted-foreground">{phase.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
}

View File

@@ -1,3 +1,5 @@
export { useSpecLoading } from './use-spec-loading';
export { useSpecSave } from './use-spec-save';
export { useSpecGeneration } from './use-spec-generation';
export { useSpecParser } from './use-spec-parser';
export type { UseSpecParserResult } from './use-spec-parser';

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import {
xmlToSpec,
isValidSpecXml,
type ParseResult,
type SpecOutput,
} from '@automaker/spec-parser';
/**
* Result of the spec parsing hook.
*/
export interface UseSpecParserResult {
/** Whether the XML is valid */
isValid: boolean;
/** The parsed spec object, or null if parsing failed */
parsedSpec: SpecOutput | null;
/** Parsing errors, if any */
errors: string[];
/** The full parse result */
parseResult: ParseResult | null;
}
/**
* Hook to parse XML spec content into a SpecOutput object.
* Memoizes the parsing result to avoid unnecessary re-parsing.
*
* @param xmlContent - The raw XML content from app_spec.txt
* @returns Parsed spec data with validation status
*/
export function useSpecParser(xmlContent: string): UseSpecParserResult {
return useMemo(() => {
if (!xmlContent || !xmlContent.trim()) {
return {
isValid: false,
parsedSpec: null,
errors: ['No spec content provided'],
parseResult: null,
};
}
// Quick structure check first
if (!isValidSpecXml(xmlContent)) {
return {
isValid: false,
parsedSpec: null,
errors: ['Invalid XML structure - missing required elements'],
parseResult: null,
};
}
// Full parse
const parseResult = xmlToSpec(xmlContent);
return {
isValid: parseResult.success,
parsedSpec: parseResult.spec,
errors: parseResult.errors,
parseResult,
};
}, [xmlContent]);
}

View File

@@ -1,3 +1,6 @@
// Spec view mode - determines how the spec is displayed/edited
export type SpecViewMode = 'view' | 'edit' | 'source';
// Feature count options for spec generation
export type FeatureCount = 20 | 50 | 100;