Add responsive design for mobile devices

- DemoMode: slide-out sidebar drawer on mobile with overlay, mobile header
  with menu/back buttons, dynamic phone frame scaling
- Gallery: stacked header layout, smaller buttons/text, hidden zoom control
  on mobile, fixed 35% thumbnail scale
- UserStoriesPage: collapsible filter panel with badge counter, compact
  priority labels, responsive padding
- SpecsPage: responsive header with compact test results, collapsible
  filter panel with search + filter toggle button
- FeatureFilter: mobile-first design with expandable filter sections

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-01-18 13:24:28 +01:00
parent eb36f37d64
commit fafc95785f
5 changed files with 565 additions and 195 deletions
+117 -16
View File
@@ -1,10 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { PhoneFrame } from './sketchy';
import { screens, getScreen } from '../screens';
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../data';
import { getStoryUrl } from '../router';
import { ThemeToggle } from './ThemeToggle';
function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
}
interface DemoModeProps {
initialScreenId: string;
onBack: () => void;
@@ -15,6 +25,8 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
const [currentScreenId, setCurrentScreenId] = useState(initialScreenId);
const [history, setHistory] = useState<string[]>([initialScreenId]);
const [historyIndex, setHistoryIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const isMobile = useIsMobile();
const currentScreen = getScreen(currentScreenId);
const ScreenComponent = currentScreen?.component;
@@ -56,7 +68,21 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
background: 'var(--tool-bg)',
overflow: 'hidden',
transition: 'background-color 0.2s ease',
position: 'relative',
}}>
{/* Mobile overlay */}
{isMobile && sidebarOpen && (
<div
onClick={() => setSidebarOpen(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 40,
}}
/>
)}
{/* Left Sidebar */}
<div style={{
width: 280,
@@ -65,7 +91,15 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
flexDirection: 'column',
borderRight: '2px solid var(--tool-border)',
background: 'var(--tool-surface)',
transition: 'background-color 0.2s ease, border-color 0.2s ease',
transition: 'transform 0.3s ease, background-color 0.2s ease, border-color 0.2s ease',
...(isMobile ? {
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
zIndex: 50,
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
} : {}),
}}>
{/* Back button and theme toggle */}
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)', display: 'flex', gap: 8 }}>
@@ -253,24 +287,87 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Mobile header */}
{isMobile && (
<div style={{
padding: '12px 16px',
borderBottom: '1px solid var(--tool-border-light)',
background: 'var(--tool-surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}>
<button
onClick={() => setSidebarOpen(true)}
style={{
padding: '8px 12px',
background: 'none',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
fontFamily: 'var(--font-sketch)',
cursor: 'pointer',
color: 'var(--tool-text)',
fontSize: 14,
}}
>
Menu
</button>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 14,
fontWeight: 'bold',
color: 'var(--tool-text)',
flex: 1,
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{currentScreen?.name}
</span>
<button
onClick={onBack}
style={{
padding: '8px 12px',
background: 'none',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
fontFamily: 'var(--font-sketch)',
cursor: 'pointer',
color: 'var(--tool-text)',
fontSize: 14,
}}
>
Retour
</button>
</div>
)}
<div style={{
maxHeight: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? 12 : 24,
overflow: 'hidden',
}}>
<div style={{
transform: 'scale(var(--phone-scale, 1))',
transformOrigin: 'center center',
maxHeight: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<ScaledPhoneFrame>
{ScreenComponent && <ScreenComponent navigate={navigate} />}
</ScaledPhoneFrame>
<div style={{
transform: 'scale(var(--phone-scale, 1))',
transformOrigin: 'center center',
}}>
<ScaledPhoneFrame isMobile={isMobile}>
{ScreenComponent && <ScreenComponent navigate={navigate} />}
</ScaledPhoneFrame>
</div>
</div>
</div>
</div>
@@ -278,7 +375,7 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod
);
}
function ScaledPhoneFrame({ children }: { children: React.ReactNode }) {
function ScaledPhoneFrame({ children, isMobile = false }: { children: React.ReactNode; isMobile?: boolean }) {
const phoneWidth = 375;
const phoneHeight = 812;
@@ -287,20 +384,24 @@ function ScaledPhoneFrame({ children }: { children: React.ReactNode }) {
React.useEffect(() => {
const calculateScale = () => {
const availableHeight = window.innerHeight - 48; // 24px padding on each side
const availableWidth = window.innerWidth - 280 - 48; // sidebar + padding
const mobileHeaderHeight = isMobile ? 56 : 0;
const padding = isMobile ? 24 : 48;
const sidebarWidth = isMobile ? 0 : 280;
const availableHeight = window.innerHeight - padding - mobileHeaderHeight;
const availableWidth = window.innerWidth - sidebarWidth - padding;
const scaleByHeight = availableHeight / phoneHeight;
const scaleByWidth = availableWidth / phoneWidth;
const newScale = Math.min(scaleByHeight, scaleByWidth, 1);
setScale(Math.max(0.5, newScale)); // minimum 50% scale
setScale(Math.max(0.4, newScale)); // minimum 40% scale for mobile
};
calculateScale();
window.addEventListener('resize', calculateScale);
return () => window.removeEventListener('resize', calculateScale);
}, []);
}, [isMobile]);
return (
<div style={{
+59 -39
View File
@@ -1,8 +1,18 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { PhoneFrame } from './sketchy';
import { screenGroups, type Screen } from '../screens';
import { ThemeToggle } from './ThemeToggle';
function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
}
interface GalleryProps {
onSelectScreen: (screenId: string) => void;
onShowStories: () => void;
@@ -15,20 +25,27 @@ const DEFAULT_SCALE = 0.5;
export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryProps) {
const [scale, setScale] = useState(DEFAULT_SCALE);
const isMobile = useIsMobile();
return (
<div>
<div style={{
padding: '24px 32px',
padding: isMobile ? '16px' : '24px 32px',
borderBottom: '2px solid var(--tool-border)',
background: 'var(--tool-surface)',
transition: 'background-color 0.2s ease, border-color 0.2s ease',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'flex-start',
gap: isMobile ? 16 : 0,
}}>
<div>
<h1 style={{
fontFamily: 'var(--font-sketch)',
fontSize: 28,
fontSize: isMobile ? 24 : 28,
margin: 0,
color: 'var(--tool-text)',
}}>
@@ -36,7 +53,7 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
</h1>
<p style={{
fontFamily: 'var(--font-sketch)',
fontSize: 16,
fontSize: 14,
color: 'var(--tool-text-muted)',
margin: '8px 0 0 0',
}}>
@@ -47,7 +64,8 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
<div style={{
display: 'flex',
alignItems: 'center',
gap: 24,
gap: isMobile ? 8 : 24,
flexWrap: 'wrap',
}}>
{/* User Stories button */}
<button
@@ -56,9 +74,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
background: 'none',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
padding: '8px 16px',
padding: isMobile ? '6px 12px' : '8px 16px',
fontFamily: 'var(--font-sketch)',
fontSize: 14,
fontSize: isMobile ? 12 : 14,
cursor: 'pointer',
color: 'var(--tool-text)',
}}
@@ -75,9 +93,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
color: 'var(--tool-bg)',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
padding: '8px 16px',
padding: isMobile ? '6px 12px' : '8px 16px',
fontFamily: 'var(--font-sketch)',
fontSize: 14,
fontSize: isMobile ? 12 : 14,
cursor: 'pointer',
}}
>
@@ -85,27 +103,29 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
</button>
)}
{/* Zoom control */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
fontFamily: 'var(--font-sketch)',
}}>
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
<input
type="range"
min={MIN_SCALE * 100}
max={MAX_SCALE * 100}
value={scale * 100}
onChange={(e) => setScale(Number(e.target.value) / 100)}
style={{
width: 100,
accentColor: 'var(--tool-text)',
}}
/>
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
</div>
{/* Zoom control - hide on mobile */}
{!isMobile && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
fontFamily: 'var(--font-sketch)',
}}>
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
<input
type="range"
min={MIN_SCALE * 100}
max={MAX_SCALE * 100}
value={scale * 100}
onChange={(e) => setScale(Number(e.target.value) / 100)}
style={{
width: 100,
accentColor: 'var(--tool-text)',
}}
/>
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
</div>
)}
{/* Theme toggle */}
<ThemeToggle />
@@ -113,14 +133,14 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
</div>
</div>
<div style={{ padding: '24px 0' }}>
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
{screenGroups.map((group) => (
<div key={group.id} style={{ marginBottom: 32 }}>
<div key={group.id} style={{ marginBottom: isMobile ? 24 : 32 }}>
{/* Group header */}
<h2 style={{
fontFamily: 'var(--font-sketch)',
fontSize: 18,
margin: '0 0 16px 32px',
fontSize: isMobile ? 16 : 18,
margin: isMobile ? '0 0 12px 16px' : '0 0 16px 32px',
color: 'var(--tool-text)',
}}>
{group.name}
@@ -129,9 +149,9 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
{/* Horizontal scrolling row */}
<div style={{
display: 'flex',
gap: 24,
paddingLeft: 32,
paddingRight: 32,
gap: isMobile ? 12 : 24,
paddingLeft: isMobile ? 16 : 32,
paddingRight: isMobile ? 16 : 32,
overflowX: 'auto',
paddingBottom: 8,
}}>
@@ -139,7 +159,7 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
<GalleryItem
key={screen.id}
screen={screen}
scale={scale}
scale={isMobile ? 0.35 : scale}
onClick={() => onSelectScreen(screen.id)}
/>
))}
+250 -105
View File
@@ -12,6 +12,16 @@ import {
import { getScreen, screens } from '../screens';
import { ThemeToggle } from './ThemeToggle';
function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
}
interface UserStoriesPageProps {
selectedStoryId?: string;
onBack: () => void;
@@ -24,7 +34,9 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
const [selectedCategories, setSelectedCategories] = useState<Set<StoryCategory>>(new Set());
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
const [filtersExpanded, setFiltersExpanded] = useState(false);
const storyRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const isMobile = useIsMobile();
// Scroll to selected story on mount
useEffect(() => {
@@ -105,34 +117,39 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
<div style={{ minHeight: '100vh', background: 'var(--tool-bg)', transition: 'background-color 0.2s ease' }}>
{/* Header */}
<div style={{
padding: '24px 32px',
padding: isMobile ? '16px' : '24px 32px',
borderBottom: '2px solid var(--tool-border)',
background: 'var(--tool-surface)',
display: 'flex',
alignItems: 'center',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center',
justifyContent: 'space-between',
gap: isMobile ? 12 : 0,
transition: 'background-color 0.2s ease, border-color 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<button
onClick={onBack}
style={{
background: 'none',
border: '2px solid var(--tool-border)',
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
padding: '8px 16px',
fontFamily: 'var(--font-sketch)',
fontSize: 14,
cursor: 'pointer',
color: 'var(--tool-text)',
}}
>
Retour
</button>
<div style={{ display: 'flex', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 12 : 16, flexDirection: isMobile ? 'column' : 'row' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: isMobile ? '100%' : 'auto', justifyContent: 'space-between' }}>
<button
onClick={onBack}
style={{
background: 'none',
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)',
}}
>
Retour
</button>
{isMobile && <ThemeToggle />}
</div>
<div>
<h1 style={{
fontFamily: 'var(--font-sketch)',
fontSize: 28,
fontSize: isMobile ? 22 : 28,
margin: 0,
color: 'var(--tool-text)',
}}>
@@ -140,113 +157,241 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use
</h1>
<p style={{
fontFamily: 'var(--font-sketch)',
fontSize: 16,
fontSize: isMobile ? 13 : 16,
color: 'var(--tool-text-muted)',
margin: '8px 0 0 0',
}}>
{filteredStories.length} / {userStories.length} stories · Cliquez sur un écran pour voir le mockup
{filteredStories.length} / {userStories.length} stories
</p>
</div>
</div>
<ThemeToggle />
{!isMobile && <ThemeToggle />}
</div>
{/* Filter bar */}
<div style={{
padding: '16px 32px',
borderBottom: '1px solid var(--tool-border-light)',
background: 'var(--tool-surface)',
display: 'flex',
flexDirection: 'column',
gap: 12,
transition: 'background-color 0.2s ease, border-color 0.2s ease',
}}>
{/* Category filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Catégorie
</span>
{categories.map(cat => (
<FilterChip
key={cat}
label={categoryLabels[cat]}
color={categoryColors[cat]}
selected={selectedCategories.has(cat)}
onClick={() => toggleCategory(cat)}
/>
))}
</div>
{/* Priority filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Priorité
</span>
{[0, 1, 2, 3].map(p => (
<FilterChip
key={p}
label={`P${p} ${priorityLabels[p]}`}
color={priorityColors[p] ?? '#888'}
selected={selectedPriorities.has(p)}
onClick={() => togglePriority(p)}
/>
))}
</div>
{/* Screen filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Écran
</span>
{screensWithStories.map(screen => (
<FilterChip
key={screen.id}
label={screen.name}
color="var(--tool-text)"
selected={selectedScreens.has(screen.id)}
onClick={() => toggleScreen(screen.id)}
/>
))}
</div>
{/* Clear filters */}
{hasFilters && (
{isMobile ? (
/* Mobile: Collapsible filter bar */
<div style={{
borderBottom: '1px solid var(--tool-border-light)',
background: 'var(--tool-surface)',
transition: 'background-color 0.2s ease, border-color 0.2s ease',
}}>
{/* Filter toggle button */}
<button
onClick={clearFilters}
onClick={() => setFiltersExpanded(!filtersExpanded)}
style={{
alignSelf: 'flex-start',
width: '100%',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'none',
border: 'none',
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: '#c00',
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
color: 'var(--tool-text)',
}}
>
Effacer les filtres
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> Filtres</span>
{hasFilters && (
<span style={{
background: 'var(--tool-text)',
color: 'var(--tool-bg)',
borderRadius: '50%',
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
}}>
{selectedCategories.size + selectedPriorities.size}
</span>
)}
</span>
<span>{filtersExpanded ? '▲' : '▼'}</span>
</button>
)}
</div>
{/* Expandable filter panel */}
{filtersExpanded && (
<div style={{
padding: '0 16px 12px',
display: 'flex',
flexDirection: 'column',
gap: 12,
borderTop: '1px solid var(--tool-border-light)',
paddingTop: 12,
}}>
{/* Category filters */}
<div>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 11,
color: 'var(--tool-text-muted)',
display: 'block',
marginBottom: 6,
}}>
Catégorie
</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{categories.map(cat => (
<FilterChip
key={cat}
label={categoryLabels[cat]}
color={categoryColors[cat]}
selected={selectedCategories.has(cat)}
onClick={() => toggleCategory(cat)}
/>
))}
</div>
</div>
{/* Priority filters */}
<div>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 11,
color: 'var(--tool-text-muted)',
display: 'block',
marginBottom: 6,
}}>
Priorité
</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[0, 1, 2, 3].map(p => (
<FilterChip
key={p}
label={`P${p}`}
color={priorityColors[p] ?? '#888'}
selected={selectedPriorities.has(p)}
onClick={() => togglePriority(p)}
/>
))}
</div>
</div>
{/* Clear filters */}
{hasFilters && (
<button
onClick={clearFilters}
style={{
alignSelf: 'flex-start',
background: 'none',
border: 'none',
fontFamily: 'var(--font-sketch)',
fontSize: 12,
color: '#c00',
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
}}
>
Effacer les filtres
</button>
)}
</div>
)}
</div>
) : (
/* Desktop: Full filter bar */
<div style={{
padding: '16px 32px',
borderBottom: '1px solid var(--tool-border-light)',
background: 'var(--tool-surface)',
display: 'flex',
flexDirection: 'column',
gap: 12,
transition: 'background-color 0.2s ease, border-color 0.2s ease',
}}>
{/* Category filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Catégorie
</span>
{categories.map(cat => (
<FilterChip
key={cat}
label={categoryLabels[cat]}
color={categoryColors[cat]}
selected={selectedCategories.has(cat)}
onClick={() => toggleCategory(cat)}
/>
))}
</div>
{/* Priority filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Priorité
</span>
{[0, 1, 2, 3].map(p => (
<FilterChip
key={p}
label={`P${p} ${priorityLabels[p]}`}
color={priorityColors[p] ?? '#888'}
selected={selectedPriorities.has(p)}
onClick={() => togglePriority(p)}
/>
))}
</div>
{/* Screen filters */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: 'var(--tool-text-muted)',
minWidth: 70,
}}>
Écran
</span>
{screensWithStories.map(screen => (
<FilterChip
key={screen.id}
label={screen.name}
color="var(--tool-text)"
selected={selectedScreens.has(screen.id)}
onClick={() => toggleScreen(screen.id)}
/>
))}
</div>
{/* Clear filters */}
{hasFilters && (
<button
onClick={clearFilters}
style={{
alignSelf: 'flex-start',
background: 'none',
border: 'none',
fontFamily: 'var(--font-sketch)',
fontSize: 13,
color: '#c00',
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
}}
>
Effacer les filtres
</button>
)}
</div>
)}
{/* Stories by priority */}
<div style={{ padding: 32 }}>
<div style={{ padding: isMobile ? 16 : 32 }}>
{storiesByPriority.length === 0 ? (
<p style={{
fontFamily: 'var(--font-sketch)',
+110 -6
View File
@@ -1,10 +1,21 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { ChevronDown, ChevronUp, Filter } from 'lucide-react';
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../data';
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
function useIsMobile(breakpoint = 640) {
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
}
interface FeatureFilterProps {
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
@@ -22,6 +33,9 @@ export function FeatureFilter({
searchQuery,
onSearchChange,
}: FeatureFilterProps) {
const [filtersExpanded, setFiltersExpanded] = useState(false);
const isMobile = useIsMobile();
const toggleCategory = (cat: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(cat)) {
@@ -49,14 +63,104 @@ export function FeatureFilter({
};
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || searchQuery;
const activeFilterCount = selectedCategories.size + selectedPriorities.size + (searchQuery ? 1 : 0);
// On mobile, show compact filter bar with expand button
if (isMobile) {
return (
<div className="border-b border-border bg-muted/30">
{/* Compact header with search and filter toggle */}
<div className="px-4 py-3 flex items-center gap-2">
<div className="flex-1">
<Input
type="search"
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="bg-background text-sm h-9"
/>
</div>
<Button
variant={activeFilterCount > 0 ? 'default' : 'outline'}
size="sm"
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="shrink-0 h-9"
>
<Filter className="w-4 h-4 mr-1" />
{activeFilterCount > 0 ? activeFilterCount : ''}
{filtersExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
</div>
{/* Expandable filter panel */}
{filtersExpanded && (
<div className="px-4 pb-3 space-y-3 border-t border-border/50 pt-3">
{/* Category filters */}
<div>
<span className="text-xs text-muted-foreground block mb-2">Catégorie</span>
<div className="flex gap-1.5 flex-wrap">
{categories.map(cat => (
<Button
key={cat}
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
size="sm"
className="text-xs px-2 h-7"
style={{
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
borderColor: categoryColors[cat],
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
}}
onClick={() => toggleCategory(cat)}
>
{categoryLabels[cat]}
</Button>
))}
</div>
</div>
{/* Priority filters */}
<div>
<span className="text-xs text-muted-foreground block mb-2">Priorité</span>
<div className="flex gap-1.5 flex-wrap">
{[0, 1, 2, 3].map(p => (
<Button
key={p}
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
size="sm"
className="text-xs px-2 h-7"
style={{
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
borderColor: priorityColors[p],
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
}}
onClick={() => togglePriority(p)}
>
P{p}
</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">
Effacer les filtres
</Button>
)}
</div>
)}
</div>
);
}
// Desktop layout
return (
<div className="border-b border-border bg-muted/30 px-8 py-4 space-y-4">
{/* Search */}
<div className="max-w-md">
<Input
type="search"
placeholder="Rechercher une fonctionnalite..."
placeholder="Rechercher une fonctionnalité..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="bg-background"
@@ -64,8 +168,8 @@ export function FeatureFilter({
</div>
{/* Category filters */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-muted-foreground w-20 shrink-0">Categorie</span>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground w-20 shrink-0">Catégorie</span>
<div className="flex gap-2 flex-wrap">
{categories.map(cat => (
<Button
@@ -86,8 +190,8 @@ export function FeatureFilter({
</div>
{/* Priority filters */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorite</span>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorité</span>
<div className="flex gap-2 flex-wrap">
{[0, 1, 2, 3].map(p => (
<Button
+29 -29
View File
@@ -66,54 +66,54 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border px-8 py-6 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="border-b border-border px-4 sm:px-8 py-4 sm:py-6 bg-card">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4">
<Button variant="outline" size="sm" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour
<ArrowLeft className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Retour</span>
</Button>
<div>
<h1 className="text-2xl font-semibold">Specifications BDD</h1>
<p className="text-sm text-muted-foreground mt-1">
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalites - {filteredFeatures.reduce((acc, f) => acc + f.scenarios.length, 0)} scenarios
<h1 className="text-xl sm:text-2xl font-semibold">Specs BDD</h1>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalités
</p>
</div>
<div className="sm:hidden ml-auto">
<ThemeToggle />
</div>
</div>
{/* Test Results Summary */}
{testSummary.totalScenarios > 0 && (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span className="text-green-600 font-medium">{testSummary.passed} passes</span>
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm flex-wrap">
<div className="flex items-center gap-1 sm:gap-2">
<CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
<span className="text-green-600 font-medium">{testSummary.passed}</span>
</div>
{testSummary.failed > 0 && (
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600 font-medium">{testSummary.failed} echecs</span>
<div className="flex items-center gap-1 sm:gap-2">
<XCircle className="w-3 h-3 sm:w-4 sm:h-4 text-red-500" />
<span className="text-red-600 font-medium">{testSummary.failed}</span>
</div>
)}
{testSummary.skipped > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-600 font-medium">{testSummary.skipped} ignores</span>
<div className="flex items-center gap-1 sm:gap-2">
<AlertCircle className="w-3 h-3 sm:w-4 sm:h-4 text-yellow-500" />
<span className="text-yellow-600 font-medium">{testSummary.skipped}</span>
</div>
)}
{testSummary.lastRun && (
<span className="text-muted-foreground">
{testSummary.lastRun.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
</span>
)}
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer">
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer" className="hidden sm:block">
<Button variant="outline" size="sm">
<ExternalLink className="w-4 h-4 mr-2" />
Rapport HTML
Rapport
</Button>
</a>
<ThemeToggle />
<div className="hidden sm:block">
<ThemeToggle />
</div>
</div>
)}
{testSummary.totalScenarios === 0 && <ThemeToggle />}
{testSummary.totalScenarios === 0 && <div className="hidden sm:block"><ThemeToggle /></div>}
</div>
</div>
@@ -128,7 +128,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
/>
{/* Feature list */}
<div className="px-8 py-6 space-y-8">
<div className="px-4 sm:px-8 py-4 sm:py-6 space-y-6 sm:space-y-8">
{featuresByPriority.map(({ priority, features }) => (
<div key={priority}>
<div className="flex items-center gap-3 mb-4">
@@ -146,7 +146,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{features.map(feature => (
<FeatureCard
key={feature.id}