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
+118 -86
View File
@@ -1,127 +1,159 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
// ============================================================================
// Route types
// ============================================================================
type Route =
| { page: 'gallery' }
| { page: 'demo'; screenId: string }
| { page: 'specs'; featureId?: string; storyId?: string };
| { page: 'welcome' }
| { page: 'login' }
| { page: 'home' }
| { page: 'events' }
| { page: 'create-event' }
| { page: 'event-detail'; eventId: string }
| { page: 'update-event'; eventId: string }
| { page: 'invite'; eventId: string }
| { page: 'participants'; eventId: string }
| { page: 'meeting-points'; eventId: string }
| { page: 'profile' }
| { page: 'edit-profile' }
| { page: 'friends' }
| { page: 'share-profile' }
| { page: 'connect' }
| { page: 'user-profile'; userId: string }
| { page: 'settings' };
export type { Route };
export interface RouteParams {
eventId?: string;
userId?: string;
}
// ============================================================================
// Path parsing & generation
// ============================================================================
function parsePath(pathname: string): Route {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/' || path === '') return { page: 'welcome' };
if (path === '/login') return { page: 'login' };
if (path === '/home') return { page: 'home' };
if (path === '/events') return { page: 'events' };
if (path === '/events/new') return { page: 'create-event' };
if (path === '/settings') return { page: 'settings' };
if (path === '/profile') return { page: 'profile' };
if (path === '/profile/edit') return { page: 'edit-profile' };
if (path === '/profile/friends') return { page: 'friends' };
if (path === '/profile/share') return { page: 'share-profile' };
if (path === '/profile/connect') return { page: 'connect' };
// /events/:id/...
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
if (eventMatch) {
const eventId = eventMatch[1]!;
const sub = eventMatch[2];
if (!sub) return { page: 'event-detail', eventId };
if (sub === 'edit') return { page: 'update-event', eventId };
if (sub === 'invite') return { page: 'invite', eventId };
if (sub === 'participants') return { page: 'participants', eventId };
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
}
// /users/:id
const userMatch = path.match(/^\/users\/([^/]+)$/);
if (userMatch) {
return { page: 'user-profile', userId: userMatch[1]! };
}
return { page: 'welcome' };
}
export function routeToPath(route: Route): string {
switch (route.page) {
case 'welcome': return '/';
case 'login': return '/login';
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'event-detail': return `/events/${route.eventId}`;
case 'update-event': return `/events/${route.eventId}/edit`;
case 'invite': return `/events/${route.eventId}/invite`;
case 'participants': return `/events/${route.eventId}/participants`;
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends': return '/profile/friends';
case 'share-profile': return '/profile/share';
case 'connect': return '/profile/connect';
case 'user-profile': return `/users/${route.userId}`;
case 'settings': return '/settings';
}
}
// ============================================================================
// Router context
// ============================================================================
interface RouterContextValue {
route: Route;
navigate: (route: Route) => void;
navigate: (path: string) => void;
goBack: () => void;
params: RouteParams;
}
const RouterContext = createContext<RouterContextValue | null>(null);
function parseHash(hash: string): Route {
const path = hash.replace(/^#\/?/, '') || '/';
if (path === '/' || path === '') {
return { page: 'gallery' };
}
// Redirect /stories to /specs (backward compatibility)
if (path === '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: 'specs', storyId };
}
}
if (path.startsWith('demo/')) {
const screenId = path.replace('demo/', '');
if (screenId) {
return { page: 'demo', screenId };
}
}
if (path === 'specs') {
return { page: 'specs' };
}
if (path.startsWith('specs/')) {
const featureId = path.replace('specs/', '');
if (featureId) {
return { page: 'specs', featureId };
}
}
return { page: 'gallery' };
}
function routeToHash(route: Route): string {
switch (route.page) {
case 'gallery':
return '#/';
case 'demo':
return `#/demo/${route.screenId}`;
case 'specs':
if (route.featureId) return `#/specs/${route.featureId}`;
if (route.storyId) return `#/specs/${route.storyId}`;
return '#/specs';
}
}
export function RouterProvider({ children }: { children: React.ReactNode }) {
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
const [route, setRoute] = useState<Route>(() => parsePath(window.location.pathname));
useEffect(() => {
const handleHashChange = () => {
setRoute(parseHash(window.location.hash));
const handlePopState = () => {
setRoute(parsePath(window.location.pathname));
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const navigate = useCallback((newRoute: Route) => {
window.location.hash = routeToHash(newRoute);
const navigate = useCallback((path: string) => {
window.history.pushState(null, '', path);
setRoute(parsePath(path));
}, []);
const goBack = useCallback(() => {
window.history.back();
}, []);
const params: RouteParams = {};
if ('eventId' in route) params.eventId = route.eventId;
if ('userId' in route) params.userId = route.userId;
return (
<RouterContext.Provider value={{ route, navigate, goBack }}>
<RouterContext.Provider value={{ route, navigate, goBack, params }}>
{children}
</RouterContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
export function useRouter() {
const context = useContext(RouterContext);
if (!context) {
throw new Error('useRouter must be used within a RouterProvider');
}
if (!context) throw new Error('useRouter must be used within a RouterProvider');
return context;
}
export function useNavigate() {
const { navigate } = useRouter();
return navigate;
return useRouter().navigate;
}
export function useGoBack() {
const { goBack } = useRouter();
return goBack;
return useRouter().goBack;
}
/**
* Generate a URL for a specific story (now redirects to specs)
*/
export function getStoryUrl(storyId: string): string {
return `#/specs/${storyId}`;
}
/**
* Generate a URL for a specific feature spec
*/
export function getSpecUrl(featureId: string): string {
return `#/specs/${featureId}`;
export function useParams(): RouteParams {
return useRouter().params;
}