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

@@ -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}