Modern UI port, render-based @ui tests, dev seed, layer contracts

- Port modern clean theme (DM Sans, orange accent, app-* CSS classes)
  and screen redesigns from festipod-mockups; replace sketchy Ubuntu
  theme. New shared components: BottomNav, EventCover, EventMeetingPoints,
  Toast, AvatarStack, Tag, RelevanceIcon.

- Restructure from prototyping shell to real mobile web app:
  path-based routing (History API), Gallery/DemoMode/PhoneFrame removed,
  Storybook setup for screen/component browsing.

- ConnectScreen ported from mockup (QR-based user connection); routed
  at /profile/connect, wired from FriendsListScreen.

- Dev-only auto-seed of NG wallet when empty
  (gated on NODE_ENV !== 'production'); bootstrapWallet already
  self-checks for non-empty ngSet so safe even in race conditions.

- Render-based @ui test infrastructure: happy-dom + LocalDataProvider +
  RouterProvider via src/shared/test-harness/renderHelper.tsx, exposed
  on the world as renderedDoc. world.hasText/hasField/hasElement prefer
  the rendered DOM and fall back to source for backward compatibility.

- Migrate 25 brittle @ui assertions from regex-on-source to DOM
  queries; delete implementation-detail tests (showDuplicateWarning,
  importableEvents, importedFrom — anti-patterns per the new contract).
  Update feature files where the UI changed: "Mes amis" → "Mon réseau",
  "Mes événements à venir" → "À venir" on home, Thématique removed
  from create-event wizard, etc.

- Path-based @e2e steps (pushState + popstate dispatch) replacing the
  legacy "#/demo/…" hash routing tied to the deleted Gallery.

- Add .project/knowledge/test-layer-contracts.md defining the role of
  each test layer (@ui = display with seed data + DOM, @data = mutations
  through NG broker, @e2e = critical user journeys) with anti-patterns
  and migration consequences.

Test status: 75 passed / 71 skipped (explicit "non implémenté")
/ 2 failed (pre-existing @wip on ngSet.delete() NG ORM limitation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-05-18 09:49:50 +02:00
parent 7099c817db
commit 5a29938130
91 changed files with 5474 additions and 6362 deletions
+50 -8
View File
@@ -1,9 +1,11 @@
import React from 'react';
interface AvatarProps {
initials?: string;
size?: 'sm' | 'md' | 'lg';
name?: string;
color?: string;
size?: 'sm' | 'md' | 'lg' | number;
className?: string;
online?: boolean;
border?: string;
}
const sizeMap = {
@@ -12,19 +14,59 @@ const sizeMap = {
lg: 56,
};
export function Avatar({ initials = '?', size = 'md', className = '' }: AvatarProps) {
const pixelSize = sizeMap[size];
export function Avatar({ initials, name, color, size = 'md', className = '', online, border }: AvatarProps) {
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
const displayInitials = initials || (name ? name.split(' ').map(n => n[0]).join('').slice(0, 2) : '?');
const bg = color || '#999';
return (
<div
className={`sketchy-avatar ${className}`}
className={`app-avatar ${className}`}
style={{
width: pixelSize,
height: pixelSize,
fontSize: pixelSize * 0.45,
fontSize: pixelSize * 0.38,
background: bg,
border: border || 'none',
}}
>
{initials}
{displayInitials}
{online && <div className="online-dot" />}
</div>
);
}
interface AvatarStackProps {
people: Array<{ name: string; color: string }>;
size?: number;
}
export function AvatarStack({ people, size = 28 }: AvatarStackProps) {
return (
<div style={{ display: 'flex' }}>
{people.slice(0, 4).map((p, i) => (
<div key={i} style={{ marginLeft: i > 0 ? -8 : 0, zIndex: people.length - i }}>
<Avatar name={p.name} color={p.color} size={size} border="2px solid #fff" />
</div>
))}
{people.length > 4 && (
<div style={{
marginLeft: -8,
width: size,
height: size,
borderRadius: '50%',
background: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 600,
color: '#666',
border: '2px solid #fff',
}}>
+{people.length - 4}
</div>
)}
</div>
);
}
+48 -1
View File
@@ -8,8 +8,55 @@ interface BadgeProps {
export function Badge({ children, className = '', style }: BadgeProps) {
return (
<span className={`sketchy-badge ${className}`} style={style}>
<span className={`app-badge ${className}`} style={style}>
{children}
</span>
);
}
interface TagProps {
label: string;
color?: string;
bg?: string;
className?: string;
}
export function Tag({ label, color, bg, className = '' }: TagProps) {
return (
<span
className={`app-tag ${className}`}
style={{
...(color ? { color } : {}),
...(bg ? { background: bg } : {}),
}}
>
{label}
</span>
);
}
interface RelevanceIconProps {
level?: number;
}
export function RelevanceIcon({ level }: RelevanceIconProps) {
if (!level) return null;
const icons: Record<number, string> = { 1: '+', 2: '++', 3: '+++' };
const colors: Record<number, string> = { 1: '#D69E2E', 2: '#E8590C', 3: '#C53030' };
const bgs: Record<number, string> = { 1: '#FFFBEB', 2: '#FFF7ED', 3: '#FFF5F5' };
return (
<div style={{
background: bgs[level],
borderRadius: 8,
padding: '3px 10px',
fontSize: 13,
fontWeight: 800,
color: colors[level],
whiteSpace: 'nowrap',
letterSpacing: -0.5,
fontFamily: 'monospace',
}}>
{icons[level]}
</div>
);
}
@@ -0,0 +1,33 @@
import { NavBar } from './NavBar';
import { useNavigate, useRouter } from '../../../app/router';
type ActiveTab = 'home' | 'friends' | 'discover' | 'profile';
interface BottomNavProps {
active?: ActiveTab;
}
function deriveActive(page: string): ActiveTab | undefined {
if (page === 'home') return 'home';
if (page === 'friends') return 'friends';
if (page === 'events' || page === 'event-detail' || page === 'create-event') return 'discover';
if (page === 'profile' || page === 'edit-profile' || page === 'share-profile') return 'profile';
return undefined;
}
export function BottomNav({ active }: BottomNavProps) {
const navigate = useNavigate();
const { route } = useRouter();
const current = active ?? deriveActive(route.page);
return (
<NavBar
items={[
{ icon: '◎', label: 'Accueil', active: current === 'home', onClick: () => navigate('/home') },
{ icon: '⬡', label: 'Réseau', active: current === 'friends', onClick: () => navigate('/profile/friends') },
{ icon: '✧', label: 'Découvrir', active: current === 'discover', onClick: () => navigate('/events') },
{ icon: '○', label: 'Profil', active: current === 'profile', onClick: () => navigate('/profile') },
]}
/>
);
}
@@ -1,57 +0,0 @@
import React from 'react';
import { useNextGraph } from '../../context/NextGraphContext';
export function BrokerBanner() {
const { status, connect } = useNextGraph();
const isConnected = status === 'connected';
const isConnecting = status === 'connecting';
const bgColor = isConnected ? '#4CAF50' : isConnecting ? '#FFB74D' : '#A5D6A7';
const textColor = isConnected ? 'white' : isConnecting ? 'white' : '#2E7D32';
return (
<div
style={{
background: bgColor,
color: textColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
fontFamily: 'var(--font-sketch)',
fontSize: 13,
fontWeight: 'bold',
flexShrink: 0,
cursor: !isConnected && !isConnecting ? 'pointer' : 'default',
}}
onClick={!isConnected && !isConnecting ? connect : undefined}
title={isConnected ? 'Connecté à NextGraph' : isConnecting ? 'Connexion en cours...' : 'Cliquer pour se connecter à NextGraph'}
>
<span>
{isConnected ? 'NextGraph' : isConnecting ? 'Connexion...' : 'Se connecter'}
</span>
{isConnected && (
<button
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
}}
title="Recharger"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 2v6h-6" />
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M3 22v-6h6" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
</svg>
</button>
)}
</div>
);
}
+22 -3
View File
@@ -1,16 +1,35 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'primary';
variant?: 'default' | 'primary' | 'green' | 'accent-outline';
children: React.ReactNode;
}
export function Button({ variant = 'default', children, className = '', ...props }: ButtonProps) {
const variantClass = variant === 'primary' ? 'sketchy-btn-primary' : '';
const variantClass = variant === 'primary' ? 'app-btn-primary'
: variant === 'green' ? 'app-btn-green'
: '';
if (variant === 'accent-outline') {
return (
<button
className={`app-btn ${className}`}
style={{
background: 'var(--app-accent-light)',
borderColor: 'var(--app-accent-border)',
color: 'var(--app-accent-dark)',
...props.style,
}}
{...props}
>
{children}
</button>
);
}
return (
<button
className={`sketchy-btn ${variantClass} ${className}`}
className={`app-btn ${variantClass} ${className}`}
{...props}
>
{children}
+24 -4
View File
@@ -5,16 +5,36 @@ interface CardProps {
className?: string;
onClick?: () => void;
style?: React.CSSProperties;
accentColor?: string;
}
export function Card({ children, className = '', onClick, style }: CardProps) {
export function Card({ children, className = '', onClick, style, accentColor }: CardProps) {
return (
<div
className={`sketchy-card ${className}`}
className={`app-card ${className}`}
onClick={onClick}
style={{ ...(onClick ? { cursor: 'pointer' } : {}), ...style }}
style={{
...(onClick ? { cursor: 'pointer' } : {}),
...(accentColor ? { overflow: 'hidden' } : {}),
...style,
}}
>
{children}
{accentColor && (
<div style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
background: accentColor,
borderRadius: '16px 0 0 16px',
}} />
)}
{accentColor ? (
<div style={{ paddingLeft: 8 }}>
{children}
</div>
) : children}
</div>
);
}
+1 -3
View File
@@ -1,5 +1,3 @@
import React from 'react';
interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
@@ -9,7 +7,7 @@ interface CheckboxProps {
export function Checkbox({ checked = false, onChange, className = '' }: CheckboxProps) {
return (
<div
className={`sketchy-checkbox ${checked ? 'checked' : ''} ${className}`}
className={`app-checkbox ${checked ? 'checked' : ''} ${className}`}
onClick={() => onChange?.(!checked)}
/>
);
+2 -8
View File
@@ -1,9 +1,3 @@
import React from 'react';
interface DividerProps {
className?: string;
}
export function Divider({ className = '' }: DividerProps) {
return <div className={`sketchy-divider ${className}`} />;
export function Divider() {
return <div className="app-divider" />;
}
@@ -0,0 +1,41 @@
import React from 'react';
const EVENT_PHOTOS: Record<string, string> = {
'1': 'https://images.unsplash.com/photo-1529119513315-c7c361862fc7?auto=format&fit=crop&w=800&q=70',
'2': 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=70',
'3': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?auto=format&fit=crop&w=800&q=70',
'4': 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=800&q=70',
default: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?auto=format&fit=crop&w=800&q=70',
};
interface EventCoverProps {
eventId?: string | number;
height?: number;
borderRadius?: number;
style?: React.CSSProperties;
children?: React.ReactNode;
}
export function EventCover({ eventId = 'default', height = 140, borderRadius = 12, style, children }: EventCoverProps) {
const url = EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
return (
<div
style={{
height,
borderRadius,
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
position: 'relative',
overflow: 'hidden',
...style,
}}
>
{children}
</div>
);
}
export function getEventPhotoUrl(eventId: string | number) {
return EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { showToast } from './Toast';
export interface MeetingPointData {
id: string | number;
title: string;
when: string;
duration: string;
lieu: string;
}
interface Props {
points: MeetingPointData[];
joinedIds: Set<string>;
onToggle: (id: string, title: string, willJoin: boolean) => void;
expanded?: boolean;
}
export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = false }: Props) {
const [showAll, setShowAll] = useState(expanded);
if (points.length === 0) return null;
const joined = points.filter(p => joinedIds.has(String(p.id)));
const others = points.filter(p => !joinedIds.has(String(p.id)));
const alwaysVisible = expanded ? points : joined;
const collapsible = expanded ? [] : others;
const handleToggle = (p: MeetingPointData) => {
const willJoin = !joinedIds.has(String(p.id));
onToggle(String(p.id), p.title, willJoin);
showToast(
willJoin ? `Inscription : ${p.title}` : `Désinscription : ${p.title}`,
willJoin ? 'success' : 'info',
);
};
const renderItem = (p: MeetingPointData) => {
const isJoined = joinedIds.has(String(p.id));
return (
<div
key={p.id}
style={{
padding: 12,
border: '1.5px solid #eee',
borderRadius: 12,
marginBottom: 8,
background: isJoined ? '#f7fff7' : '#fff',
borderColor: isJoined ? '#c6f6d5' : '#eee',
}}
>
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
{p.title}
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
🕒 {p.when} · {p.duration}
<br />
📍 {p.lieu}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={(e) => { e.stopPropagation(); handleToggle(p); }}
style={{
background: isJoined ? '#22543D' : '#1a1a1a',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '6px 14px',
fontSize: 12.5,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'var(--font-app)',
}}
>
{isJoined ? '✓ Inscrit' : "S'inscrire"}
</button>
</div>
</div>
);
};
return (
<div>
<div
style={{
fontSize: 11,
fontWeight: 700,
color: '#999',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 8,
}}
>
Points de rencontre ({points.length})
</div>
{alwaysVisible.map(renderItem)}
{collapsible.length > 0 && !showAll && (
<button
onClick={(e) => { e.stopPropagation(); setShowAll(true); }}
style={{
width: '100%',
padding: '8px 10px',
border: '1.5px dashed #ddd',
borderRadius: 10,
background: 'none',
fontSize: 12,
fontWeight: 600,
color: '#888',
cursor: 'pointer',
fontFamily: 'var(--font-app)',
marginBottom: 4,
}}
>
+ {collapsible.length} autre{collapsible.length > 1 ? 's' : ''} point{collapsible.length > 1 ? 's' : ''} de rencontre
</button>
)}
{collapsible.length > 0 && showAll && (
<>
{collapsible.map(renderItem)}
<button
onClick={(e) => { e.stopPropagation(); setShowAll(false); }}
style={{
width: '100%',
padding: '6px 10px',
border: 'none',
background: 'none',
fontSize: 12,
fontWeight: 600,
color: '#888',
cursor: 'pointer',
fontFamily: 'var(--font-app)',
}}
>
Réduire
</button>
</>
)}
</div>
);
}
+2 -2
View File
@@ -9,9 +9,9 @@ interface HeaderProps {
export function Header({ title, left, right, className = '' }: HeaderProps) {
return (
<div className={`sketchy-header ${className}`}>
<div className={`app-header ${className}`}>
<div style={{ width: 40 }}>{left}</div>
<div className="sketchy-subtitle" style={{ margin: 0 }}>{title}</div>
<div className="app-subtitle" style={{ margin: 0 }}>{title}</div>
<div style={{ width: 40, textAlign: 'right' }}>{right}</div>
</div>
);
+1 -1
View File
@@ -5,7 +5,7 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export function Input({ className = '', ...props }: InputProps) {
return (
<input
className={`sketchy-input ${className}`}
className={`app-input ${className}`}
{...props}
/>
);
+1 -1
View File
@@ -9,7 +9,7 @@ interface ListItemProps {
export function ListItem({ children, onClick, className = '' }: ListItemProps) {
return (
<div
className={`sketchy-list-item ${className}`}
className={`app-list-item ${className}`}
onClick={onClick}
>
{children}
+5 -7
View File
@@ -1,5 +1,3 @@
import React from 'react';
interface NavItem {
icon: string;
label: string;
@@ -14,23 +12,23 @@ interface NavBarProps {
export function NavBar({ items, className = '' }: NavBarProps) {
return (
<div className={`sketchy-navbar ${className}`}>
<div className={`app-navbar ${className}`}>
{items.map((item, index) => (
<div
key={index}
className={`nav-item ${item.active ? 'active' : ''}`}
onClick={item.onClick}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
gap: 3,
cursor: 'pointer',
opacity: item.active ? 1 : 0.6,
opacity: item.active ? 1 : 0.4,
color: item.active ? 'var(--app-accent)' : '#333',
}}
>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<span style={{ fontSize: 12 }}>{item.label}</span>
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.3 }}>{item.label}</span>
</div>
))}
</div>
+5 -5
View File
@@ -12,8 +12,8 @@ export function NgStatus() {
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'var(--sketch-gray)',
fontFamily: 'var(--font-sketch)',
color: '#888',
fontFamily: 'var(--font-app)',
}}
title="Mode démonstration — NextGraph non connecté"
>
@@ -37,8 +37,8 @@ export function NgStatus() {
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'var(--sketch-gray)',
fontFamily: 'var(--font-sketch)',
color: '#888',
fontFamily: 'var(--font-app)',
}}
>
<span style={{
@@ -61,7 +61,7 @@ export function NgStatus() {
gap: 4,
fontSize: 11,
color: '#4caf50',
fontFamily: 'var(--font-sketch)',
fontFamily: 'var(--font-app)',
}}
title="Connecté à NextGraph"
>
@@ -1,116 +0,0 @@
import React from 'react';
interface PhoneFrameProps {
children: React.ReactNode;
scale?: number;
className?: string;
}
export function PhoneFrame({ children, scale = 1, className = '' }: PhoneFrameProps) {
// iPhone-like dimensions (375 x 812 logical pixels)
const width = 375;
const height = 812;
return (
<div
className={`phone-frame-wrapper ${className}`}
style={{
width: width * scale,
height: height * scale,
position: 'relative',
background: 'var(--sketch-white)',
borderRadius: 40 * scale,
border: `${3 * scale}px solid var(--sketch-black)`,
boxShadow: `${4 * scale}px ${4 * scale}px 0 var(--sketch-black)`,
overflow: 'hidden',
// Sketchy irregular border effect
borderTopLeftRadius: `${42 * scale}px`,
borderTopRightRadius: `${38 * scale}px`,
borderBottomLeftRadius: `${39 * scale}px`,
borderBottomRightRadius: `${41 * scale}px`,
}}
>
{/* Notch */}
<div
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 150 * scale,
height: 28 * scale,
background: 'var(--sketch-black)',
borderBottomLeftRadius: 14 * scale,
borderBottomRightRadius: 16 * scale,
zIndex: 10,
}}
/>
{/* Screen content */}
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Status bar area */}
<div
style={{
height: 44 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: `0 ${20 * scale}px`,
fontSize: 12 * scale,
fontFamily: 'var(--font-sketch)',
flexShrink: 0,
color: 'var(--sketch-black)',
}}
>
<span>9:41</span>
<span style={{ display: 'flex', gap: 4 * scale }}>
<span>~</span>
<span>|</span>
<span>|</span>
</span>
</div>
{/* Main content area */}
<div
className="phone-screen"
style={{
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{children}
</div>
{/* Home indicator */}
<div
style={{
height: 34 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div
style={{
width: 134 * scale,
height: 5 * scale,
background: 'var(--sketch-black)',
borderRadius: 3 * scale,
}}
/>
</div>
</div>
</div>
);
}
@@ -17,7 +17,7 @@ export function Placeholder({
}: PlaceholderProps) {
return (
<div
className={`sketchy-placeholder ${className}`}
className={`app-placeholder ${className}`}
style={{ width, height, ...style }}
>
{label}
+3 -3
View File
@@ -8,13 +8,13 @@ interface TextProps {
}
export function Title({ children, className = '', style }: TextProps) {
return <h1 className={`sketchy-title ${className}`} style={style}>{children}</h1>;
return <h1 className={`app-title ${className}`} style={style}>{children}</h1>;
}
export function Subtitle({ children, className = '', style }: TextProps) {
return <h2 className={`sketchy-subtitle ${className}`} style={style}>{children}</h2>;
return <h2 className={`app-subtitle ${className}`} style={style}>{children}</h2>;
}
export function Text({ children, className = '', style, onClick }: TextProps) {
return <p className={`sketchy-text ${className}`} style={style} onClick={onClick}>{children}</p>;
return <p className={`app-text ${className}`} style={style} onClick={onClick}>{children}</p>;
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState, useCallback } from 'react';
type ToastVariant = 'success' | 'info' | 'error';
interface ToastItem {
id: number;
message: string;
variant: ToastVariant;
}
type Listener = (toasts: ToastItem[]) => void;
let items: ToastItem[] = [];
let nextId = 1;
const listeners: Set<Listener> = new Set();
function emit() {
listeners.forEach(l => l(items));
}
export function showToast(message: string, variant: ToastVariant = 'success') {
const id = nextId++;
items = [...items, { id, message, variant }];
emit();
setTimeout(() => {
items = items.filter(t => t.id !== id);
emit();
}, 2600);
}
export function ToastContainer() {
const [toasts, setToasts] = useState<ToastItem[]>(items);
useEffect(() => {
const listener: Listener = (next) => setToasts([...next]);
listeners.add(listener);
return () => { listeners.delete(listener); };
}, []);
const dismiss = useCallback((id: number) => {
items = items.filter(t => t.id !== id);
emit();
}, []);
if (toasts.length === 0) return null;
return (
<div
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 78,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
pointerEvents: 'none',
zIndex: 1000,
}}
>
{toasts.map(t => (
<div
key={t.id}
onClick={() => dismiss(t.id)}
style={{
pointerEvents: 'auto',
background: t.variant === 'error' ? '#7B2A1E' : t.variant === 'info' ? '#1F3A5F' : '#22543D',
color: '#fff',
padding: '10px 16px',
borderRadius: 14,
fontSize: 13,
fontWeight: 600,
maxWidth: '80%',
textAlign: 'center',
boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
fontFamily: 'var(--font-app)',
cursor: 'pointer',
animation: 'toast-slide-up 0.25s ease',
}}
>
{t.message}
</div>
))}
<style>{`
@keyframes toast-slide-up {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</div>
);
}
+1 -3
View File
@@ -1,5 +1,3 @@
import React from 'react';
interface ToggleProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
@@ -9,7 +7,7 @@ interface ToggleProps {
export function Toggle({ checked = false, onChange, className = '' }: ToggleProps) {
return (
<div
className={`sketchy-toggle ${checked ? 'on' : ''} ${className}`}
className={`app-toggle ${checked ? 'on' : ''} ${className}`}
onClick={() => onChange?.(!checked)}
/>
);
+7 -4
View File
@@ -3,13 +3,16 @@ export { Input } from './Input';
export { Card } from './Card';
export { Title, Subtitle, Text } from './Text';
export { Placeholder } from './Placeholder';
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Avatar, AvatarStack } from './Avatar';
export { Badge, Tag, RelevanceIcon } from './Badge';
export { Toggle } from './Toggle';
export { Checkbox } from './Checkbox';
export { ListItem } from './ListItem';
export { Header } from './Header';
export { NavBar } from './NavBar';
export { BottomNav } from './BottomNav';
export { ToastContainer, showToast } from './Toast';
export { EventCover, getEventPhotoUrl } from './EventCover';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { Divider } from './Divider';
export { PhoneFrame } from './PhoneFrame';
export { BrokerBanner } from './BrokerBanner';
+27 -2
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
import type {
FpEventData,
FpUserData,
@@ -257,6 +257,31 @@ function useNgData(): FestipodDataContextValue {
}
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
const t = setTimeout(() => {
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
const currentUserId = currentUser?.id || '';
@@ -389,7 +414,7 @@ function useNgData(): FestipodDataContextValue {
// Provider — switches between local and NG
// ============================================================================
function LocalDataProvider({ children, empty }: { children: ReactNode; empty?: boolean }) {
export function LocalDataProvider({ children, empty }: { children: ReactNode; empty?: boolean }) {
const data = useLocalData(empty);
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { FestipodWorld } from '../../support/world';
Given('l\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {
const screenId = screenName.toLowerCase().replace(/ /g, '-');
this.navigateTo(`#/demo/${screenId}`);
await this.navigateTo(`#/demo/${screenId}`);
});
Given('le formulaire de création est vide', async function (this: FestipodWorld) {
+2 -2
View File
@@ -37,7 +37,7 @@ function resolveScreenId(pageName: string): string {
Given('je suis sur la page {string}', async function (this: FestipodWorld, pageName: string) {
const screenId = resolveScreenId(pageName);
this.navigateTo(`#/demo/${screenId}`);
await this.navigateTo(`#/demo/${screenId}`);
});
Given('je suis connecté en tant qu\'utilisateur', async function (this: FestipodWorld) {
@@ -54,7 +54,7 @@ Given('je ne suis pas connecté', async function (this: FestipodWorld) {
When('je navigue vers {string}', async function (this: FestipodWorld, pageName: string) {
const screenId = resolveScreenId(pageName);
this.navigateTo(`#/demo/${screenId}`);
await this.navigateTo(`#/demo/${screenId}`);
});
When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {
+40 -9
View File
@@ -3,6 +3,7 @@ import { getScreen, type Screen } from '../../screens/index';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { renderScreen as renderUiScreen, unmountRender } from '../test-harness/renderHelper';
export interface FestipodWorld extends World {
currentRoute: string;
@@ -15,18 +16,23 @@ export interface FestipodWorld extends World {
currentScreen: Screen | null;
screenSourceContent: string;
// Rendered DOM (UI layer, render-based)
renderedDoc: Document | null;
// Playwright (data layer)
page: Page | null;
appFrame: Frame | null;
navigateTo(route: string): void;
navigateTo(route: string): Promise<void>;
getFormField(name: string): { required: boolean; value: string } | undefined;
getCurrentScreenFields(): string[];
setScreenFields(screenId: string): void;
// Methods for screen content analysis
loadScreenSource(screenId: string): void;
renderCurrentScreen(): Promise<void>;
getRenderedText(): string;
getDomText(): string;
hasText(text: string): boolean;
hasField(fieldName: string): boolean;
hasElement(selector: string): boolean;
@@ -228,6 +234,7 @@ class CustomWorld extends World implements FestipodWorld {
// Screen analysis
currentScreen: Screen | null = null;
screenSourceContent: string = '';
renderedDoc: Document | null = null;
// Playwright (data layer testing)
page: Page | null = null;
@@ -237,15 +244,15 @@ class CustomWorld extends World implements FestipodWorld {
super(options);
}
navigateTo(route: string): void {
async navigateTo(route: string): Promise<void> {
this.navigationHistory.push(route);
this.currentRoute = route;
if (route.startsWith('#/demo/')) {
this.currentScreenId = route.replace('#/demo/', '');
this.setScreenFields(this.currentScreenId);
// Load the screen source for content verification
this.loadScreenSource(this.currentScreenId);
await this.renderCurrentScreen();
} else if (route === '#/specs' || route.startsWith('#/specs/')) {
this.currentScreenId = null;
} else if (route === '#/stories' || route.startsWith('#/stories/')) {
@@ -255,6 +262,22 @@ class CustomWorld extends World implements FestipodWorld {
}
}
async renderCurrentScreen(): Promise<void> {
if (!this.currentScreenId) return;
try {
this.renderedDoc = await renderUiScreen(this.currentScreenId);
const text = this.renderedDoc?.body?.textContent ?? '';
console.log(`[render] "${this.currentScreenId}" — body text length: ${text.length}, preview: ${text.substring(0, 100)}`);
} catch (err) {
this.renderedDoc = null;
console.warn(`[render] Failed to render "${this.currentScreenId}":`, (err as Error).message, (err as Error).stack);
}
}
getDomText(): string {
return this.renderedDoc?.body?.textContent ?? '';
}
getFormField(name: string) {
return this.formFields.get(name);
}
@@ -302,8 +325,10 @@ class CustomWorld extends World implements FestipodWorld {
}
hasText(text: string): boolean {
// Check if the text appears in the screen source
// This verifies the component contains the expected text
// Prefer the rendered DOM when available — that's what the user sees.
// Fall back to source inspection for tests that haven't been migrated.
const domText = this.getDomText();
if (domText && domText.includes(text)) return true;
return this.screenSourceContent.includes(text);
}
@@ -320,10 +345,15 @@ class CustomWorld extends World implements FestipodWorld {
}
hasElement(selector: string): boolean {
// Check for common patterns in JSX
// DOM first
if (this.renderedDoc) {
try {
if (this.renderedDoc.querySelector(selector)) return true;
} catch {
// selector might not be a valid CSS selector — fall through to source
}
}
if (!this.screenSourceContent) return false;
// Check for element types like textarea, input, button
if (selector === 'textarea') {
return this.screenSourceContent.includes('<textarea') ||
this.screenSourceContent.includes('textarea');
@@ -336,13 +366,14 @@ class CustomWorld extends World implements FestipodWorld {
return this.screenSourceContent.includes('<Button') ||
this.screenSourceContent.includes('<button');
}
return this.screenSourceContent.includes(selector);
}
cleanup(): void {
this.screenSourceContent = '';
this.currentScreen = null;
unmountRender();
this.renderedDoc = null;
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Render helper for @ui tests.
*
* Spins up a happy-dom Window, renders a screen wrapped in the local data
* provider (seedData) + router, and exposes the resulting DOM for assertions.
*
* Why happy-dom + local provider:
* - happy-dom is in-process, fast, no broker/wallet needed
* - LocalDataProvider gives us the same seed data used in disconnected mode,
* so assertions can target real values ("Marie Dupont", "@mariedupont", …)
* - We bypass NextGraphProvider entirely — those tests aren't about NG
*/
import { Window } from 'happy-dom';
import React from 'react';
import { getScreen } from '../../screens/index';
import { LocalDataProvider } from '../context/FestipodDataContext';
import { RouterProvider } from '../../app/router';
let window: Window | null = null;
let root: any | null = null;
let createRoot: any = null;
/**
* Install happy-dom globals so React/ReactDOM can run. Must be called before
* ReactDOM is imported. Idempotent.
*/
export async function ensureDomGlobals(): Promise<void> {
if (window) return;
window = new Window({ url: 'http://localhost/' });
// Install minimal globals React/ReactDOM expect. Some (e.g. `navigator` on
// Node 22+) are already defined as getter-only properties — we use
// defineProperty to override them.
const setGlobal = (name: string, value: any) => {
try {
(globalThis as any)[name] = value;
} catch {
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
}
};
setGlobal('window', window);
setGlobal('document', window.document);
setGlobal('navigator', window.navigator);
setGlobal('HTMLElement', (window as any).HTMLElement);
setGlobal('HTMLInputElement', (window as any).HTMLInputElement);
setGlobal('HTMLTextAreaElement', (window as any).HTMLTextAreaElement);
setGlobal('HTMLButtonElement', (window as any).HTMLButtonElement);
setGlobal('Element', (window as any).Element);
setGlobal('Node', (window as any).Node);
setGlobal('Event', (window as any).Event);
setGlobal('MouseEvent', (window as any).MouseEvent);
setGlobal('PopStateEvent', (window as any).PopStateEvent);
setGlobal('requestAnimationFrame', (cb: any) => setTimeout(cb, 0));
setGlobal('cancelAnimationFrame', (id: any) => clearTimeout(id));
// Import ReactDOM only after globals are installed
const reactDom = await import('react-dom/client');
createRoot = reactDom.createRoot;
}
/**
* Render a screen at the given path. Returns the rendered document.
*
* If `path` is omitted, derives a default path from the screenId via the
* screen registry. Any previous render is unmounted first.
*/
export async function renderScreen(screenId: string, path?: string): Promise<Document> {
await ensureDomGlobals();
if (!window) throw new Error('DOM globals not installed');
// Unmount any previous render to keep tests isolated
if (root) {
root.unmount();
root = null;
}
const screen = getScreen(screenId);
if (!screen) throw new Error(`Unknown screen "${screenId}"`);
// Set pathname so RouterProvider picks the right route
const targetPath = path ?? defaultPathFor(screen.path);
(window.history as any).pushState({}, '', targetPath);
// Clear & mount
const doc = window.document as unknown as Document;
doc.body.innerHTML = '<div id="root"></div>';
const container = doc.getElementById('root')!;
root = createRoot(container);
await new Promise<void>((resolve) => {
root.render(
<LocalDataProvider>
<RouterProvider>
<screen.component />
</RouterProvider>
</LocalDataProvider>,
);
// Wait one microtask for React to flush effects
setTimeout(resolve, 0);
});
return doc;
}
/**
* Convert a registry path with `:id` placeholders to a concrete URL using the
* first seed event/user when applicable. Tests can override via the explicit
* `path` argument to renderScreen().
*/
function defaultPathFor(registryPath: string): string {
// Substitute :id with a seed id matching the route's resource. /users/:id
// needs a user id, /events/:id needs an event id. user-2 is the first
// non-current user in seedData (Jean Durand).
let path = registryPath;
if (path.startsWith('/users/')) {
path = path.replace(/:id/g, 'user-2');
} else {
path = path.replace(/:id/g, 'event-1');
}
return path.replace(/\/+$/, '') || '/';
}
export function unmountRender(): void {
if (root) {
root.unmount();
root = null;
}
}
export function getRenderedDocument(): Document | null {
return (window?.document as unknown as Document) ?? null;
}