Merge User Stories page into Specs page

- SpecsPage: Add screen filter, scroll-to-story, selection highlight
- FeatureFilter: Add screen filter chips for both mobile and desktop
- Router: Redirect /stories/* routes to /specs/* for backward compatibility
- App: Remove UserStoriesPage routing, simplify navigation
- Gallery: Remove User Stories button, keep only Specs BDD
- Button: Add cursor-pointer to base styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-01-18 20:02:39 +01:00
parent 4ae96b0e94
commit fd5fab5bd2
6 changed files with 162 additions and 63 deletions
+3 -14
View File
@@ -3,7 +3,6 @@ import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from './context/ThemeContext';
import { Gallery } from './components/Gallery';
import { DemoMode } from './components/DemoMode';
import { UserStoriesPage } from './components/UserStoriesPage';
import { SpecsPage } from './components/specs';
function AppContent() {
@@ -14,17 +13,7 @@ function AppContent() {
<DemoMode
initialScreenId={route.screenId}
onBack={goBack}
onNavigateToStory={(storyId) => navigate({ page: 'stories', storyId })}
/>
);
}
if (route.page === 'stories') {
return (
<UserStoriesPage
selectedStoryId={route.storyId}
onBack={goBack}
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
onNavigateToStory={(storyId) => navigate({ page: 'specs', storyId })}
/>
);
}
@@ -33,9 +22,10 @@ function AppContent() {
return (
<SpecsPage
selectedFeatureId={route.featureId}
selectedStoryId={route.storyId}
onBack={goBack}
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
onSelectStory={(storyId) => navigate({ page: 'stories', storyId })}
onSelectStory={(storyId) => navigate({ page: 'specs', storyId })}
/>
);
}
@@ -43,7 +33,6 @@ function AppContent() {
return (
<Gallery
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
onShowStories={() => navigate({ page: 'stories' })}
onShowSpecs={() => navigate({ page: 'specs' })}
/>
);
+7 -27
View File
@@ -15,15 +15,14 @@ function useIsMobile(breakpoint = 768) {
interface GalleryProps {
onSelectScreen: (screenId: string) => void;
onShowStories: () => void;
onShowSpecs?: () => void;
onShowSpecs: () => void;
}
const MIN_SCALE = 0.32;
const MAX_SCALE = 0.75;
const DEFAULT_SCALE = 0.5;
export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryProps) {
export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
const [scale, setScale] = useState(DEFAULT_SCALE);
const isMobile = useIsMobile();
@@ -67,42 +66,23 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
gap: isMobile ? 8 : 24,
flexWrap: 'wrap',
}}>
{/* User Stories button */}
{/* Specs BDD button */}
<button
onClick={onShowStories}
onClick={onShowSpecs}
style={{
background: 'none',
background: 'var(--tool-text)',
color: 'var(--tool-bg)',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
padding: isMobile ? '6px 12px' : '8px 16px',
fontFamily: 'var(--font-sketch)',
fontSize: isMobile ? 12 : 14,
cursor: 'pointer',
color: 'var(--tool-text)',
}}
>
User Stories
Specs BDD
</button>
{/* Specs BDD button */}
{onShowSpecs && (
<button
onClick={onShowSpecs}
style={{
background: 'var(--tool-text)',
color: 'var(--tool-bg)',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
padding: isMobile ? '6px 12px' : '8px 16px',
fontFamily: 'var(--font-sketch)',
fontSize: isMobile ? 12 : 14,
cursor: 'pointer',
}}
>
Specs BDD
</button>
)}
{/* Zoom control - hide on mobile */}
{!isMobile && (
<div style={{
+63 -2
View File
@@ -16,11 +16,19 @@ function useIsMobile(breakpoint = 640) {
return isMobile;
}
interface ScreenInfo {
id: string;
screen: { id: string; name: string } | undefined;
}
interface FeatureFilterProps {
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
selectedPriorities: Set<number>;
onPrioritiesChange: (priorities: Set<number>) => void;
selectedScreens: Set<string>;
onScreensChange: (screens: Set<string>) => void;
screensWithStories: ScreenInfo[];
searchQuery: string;
onSearchChange: (query: string) => void;
}
@@ -30,6 +38,9 @@ export function FeatureFilter({
onCategoriesChange,
selectedPriorities,
onPrioritiesChange,
selectedScreens,
onScreensChange,
screensWithStories,
searchQuery,
onSearchChange,
}: FeatureFilterProps) {
@@ -56,14 +67,25 @@ export function FeatureFilter({
onPrioritiesChange(newSet);
};
const toggleScreen = (screenId: string) => {
const newSet = new Set(selectedScreens);
if (newSet.has(screenId)) {
newSet.delete(screenId);
} else {
newSet.add(screenId);
}
onScreensChange(newSet);
};
const clearFilters = () => {
onCategoriesChange(new Set());
onPrioritiesChange(new Set());
onScreensChange(new Set());
onSearchChange('');
};
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || searchQuery;
const activeFilterCount = selectedCategories.size + selectedPriorities.size + (searchQuery ? 1 : 0);
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0 || searchQuery;
const activeFilterCount = selectedCategories.size + selectedPriorities.size + selectedScreens.size + (searchQuery ? 1 : 0);
// On mobile, show compact filter bar with expand button
if (isMobile) {
@@ -141,6 +163,26 @@ export function FeatureFilter({
</div>
</div>
{/* Screen filters */}
{screensWithStories.length > 0 && (
<div>
<span className="text-xs text-muted-foreground block mb-2">Écran</span>
<div className="flex gap-1.5 flex-wrap">
{screensWithStories.map(({ id, screen }) => (
<Button
key={id}
variant={selectedScreens.has(id) ? 'default' : 'outline'}
size="sm"
className="text-xs px-2 h-7"
onClick={() => toggleScreen(id)}
>
{screen?.name || id}
</Button>
))}
</div>
</div>
)}
{/* Clear filters */}
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive text-xs p-0 h-auto">
@@ -211,6 +253,25 @@ export function FeatureFilter({
</div>
</div>
{/* Screen filters */}
{screensWithStories.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground w-20 shrink-0">Écran</span>
<div className="flex gap-2 flex-wrap">
{screensWithStories.map(({ id, screen }) => (
<Button
key={id}
variant={selectedScreens.has(id) ? 'default' : 'outline'}
size="sm"
onClick={() => toggleScreen(id)}
>
{screen?.name || id}
</Button>
))}
</div>
</div>
)}
{/* Clear filters */}
{hasFilters && (
<div>
+78 -10
View File
@@ -1,7 +1,8 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { parsedFeatures, getFeatureById } from '../../data/features';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, type StoryCategory } from '../../data';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, getScreenIdsWithStories, type StoryCategory } from '../../data';
import { getTestStatus, getTestSummary } from '../../data/testResults';
import { getScreen } from '../../screens';
import { FeatureView } from './FeatureView';
import { FeatureFilter } from './FeatureFilter';
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
@@ -12,15 +13,36 @@ import { ThemeToggle } from '../ThemeToggle';
interface SpecsPageProps {
selectedFeatureId?: string;
selectedStoryId?: string;
onBack: () => void;
onSelectScreen: (screenId: string) => void;
onSelectStory: (storyId: string) => void;
}
export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectStory }: SpecsPageProps) {
export function SpecsPage({ selectedFeatureId, selectedStoryId, onBack, onSelectScreen, onSelectStory }: SpecsPageProps) {
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const featureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Get screens that have linked stories for the filter
const screensWithStories = useMemo(() => {
const screenIds = getScreenIdsWithStories();
return screenIds
.map(id => ({ id, screen: getScreen(id) }))
.filter(({ screen }) => screen !== undefined);
}, []);
// Scroll to selected story on mount
useEffect(() => {
if (selectedStoryId) {
const element = featureRefs.current.get(selectedStoryId);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [selectedStoryId]);
// Filter features - must be before any conditional returns to respect hooks rules
const filteredFeatures = useMemo(() => {
@@ -31,6 +53,12 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
if (selectedPriorities.size > 0 && !selectedPriorities.has(feature.priority)) {
return false;
}
if (selectedScreens.size > 0) {
const linkedStory = getStoryById(feature.id);
if (!linkedStory || !linkedStory.screenIds.some(id => selectedScreens.has(id))) {
return false;
}
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
return feature.name.toLowerCase().includes(query) ||
@@ -38,7 +66,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
}
return true;
});
}, [selectedCategories, selectedPriorities, searchQuery]);
}, [selectedCategories, selectedPriorities, selectedScreens, searchQuery]);
// Group by priority
const featuresByPriority = [0, 1, 2, 3].map(priority => ({
@@ -123,6 +151,9 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
onCategoriesChange={setSelectedCategories}
selectedPriorities={selectedPriorities}
onPrioritiesChange={setSelectedPriorities}
selectedScreens={selectedScreens}
onScreensChange={setSelectedScreens}
screensWithStories={screensWithStories}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
@@ -150,8 +181,13 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
{features.map(feature => (
<FeatureCard
key={feature.id}
ref={(el) => {
if (el) featureRefs.current.set(feature.id, el);
}}
feature={feature}
isSelected={feature.id === selectedStoryId}
onClick={() => window.location.hash = `#/specs/${feature.id}`}
onSelectScreen={onSelectScreen}
/>
))}
</div>
@@ -179,11 +215,17 @@ function formatUserStory(description: string): string[] {
interface FeatureCardProps {
feature: ParsedFeature;
isSelected?: boolean;
onClick: () => void;
onSelectScreen: (screenId: string) => void;
}
function FeatureCard({ feature, onClick }: FeatureCardProps) {
const FeatureCard = React.forwardRef<HTMLDivElement, FeatureCardProps>(
function FeatureCard({ feature, isSelected, onClick, onSelectScreen }, ref) {
const linkedStory = getStoryById(feature.id);
const linkedScreens = linkedStory?.screenIds
.map(id => ({ id, screen: getScreen(id) }))
.filter(({ screen }) => screen !== undefined) || [];
const testStatus = getTestStatus(feature.id);
const getStatusIcon = () => {
@@ -210,7 +252,10 @@ function FeatureCard({ feature, onClick }: FeatureCardProps) {
return (
<Card
className="cursor-pointer hover:border-primary hover:shadow-md transition-all"
ref={ref}
className={`cursor-pointer hover:border-primary hover:shadow-md transition-all ${
isSelected ? 'border-2 border-blue-500 bg-blue-50 dark:bg-blue-950/20' : ''
}`}
onClick={onClick}
>
<CardHeader className="pb-3">
@@ -247,19 +292,42 @@ function FeatureCard({ feature, onClick }: FeatureCardProps) {
))}
</div>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{feature.scenarios.length} scenarios
</span>
{linkedStory && linkedStory.screenIds.length > 0 && (
{linkedScreens.length > 0 && (
<span className="flex items-center gap-1">
<Monitor className="w-3 h-3" />
{linkedStory.screenIds.length} ecrans
{linkedScreens.length} ecrans
</span>
)}
</div>
{/* Screen buttons */}
{linkedScreens.length > 0 ? (
<div className="flex gap-2 flex-wrap">
{linkedScreens.map(({ id, screen }) => (
<Button
key={id}
variant="outline"
size="sm"
className="text-xs"
onClick={(e) => {
e.stopPropagation();
onSelectScreen(id);
}}
>
{screen!.name}
</Button>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
Pas encore de mockup
</p>
)}
</CardContent>
</Card>
);
}
});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+10 -9
View File
@@ -2,9 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
type Route =
| { page: 'gallery' }
| { page: 'stories'; storyId?: string }
| { page: 'demo'; screenId: string }
| { page: 'specs'; featureId?: string };
| { page: 'specs'; featureId?: string; storyId?: string };
interface RouterContextValue {
route: Route;
@@ -21,14 +20,16 @@ function parseHash(hash: string): Route {
return { page: 'gallery' };
}
// Redirect /stories to /specs (backward compatibility)
if (path === 'stories') {
return { page: 'stories' };
return { page: 'specs' };
}
// Redirect /stories/{id} to /specs with storyId (backward compatibility)
if (path.startsWith('stories/')) {
const storyId = path.replace('stories/', '');
if (storyId) {
return { page: 'stories', storyId };
return { page: 'specs', storyId };
}
}
@@ -57,12 +58,12 @@ function routeToHash(route: Route): string {
switch (route.page) {
case 'gallery':
return '#/';
case 'stories':
return route.storyId ? `#/stories/${route.storyId}` : '#/stories';
case 'demo':
return `#/demo/${route.screenId}`;
case 'specs':
return route.featureId ? `#/specs/${route.featureId}` : '#/specs';
if (route.featureId) return `#/specs/${route.featureId}`;
if (route.storyId) return `#/specs/${route.storyId}`;
return '#/specs';
}
}
@@ -112,10 +113,10 @@ export function useGoBack() {
}
/**
* Generate a URL for a specific story
* Generate a URL for a specific story (now redirects to specs)
*/
export function getStoryUrl(storyId: string): string {
return `#/stories/${storyId}`;
return `#/specs/${storyId}`;
}
/**