You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

89 KiB

Frontend Implementation Plan: AI Weekly Synth (SolidJS Rewrite)

Date: 2026-03-21 Role: Frontend Implementation Planner Target: Complete SolidJS frontend to replace the current React SPA


Table of Contents

  1. Project Structure
  2. Component Architecture
  3. State Management
  4. Routing
  5. i18n Architecture
  6. SSE Integration
  7. Tailwind CSS
  8. Forms and Validation
  9. PDF/Markdown Export
  10. React-to-SolidJS Migration Map
  11. Testing

1. Project Structure

1.1 Directory Layout

frontend/
├── index.html                          # Vite entry HTML
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── vite.config.ts
├── tailwind.config.ts                  # Tailwind v4 config (if needed beyond CSS)
├── public/
│   └── favicon.svg
├── src/
│   ├── index.tsx                       # SolidJS render entry point
│   ├── App.tsx                         # Root component: router + providers
│   ├── index.css                       # Tailwind import + custom base styles
│   │
│   ├── api/                            # API client layer
│   │   ├── client.ts                   # Base fetch wrapper (cookies, CSRF, errors)
│   │   ├── auth.ts                     # Auth endpoints (register, login, verify, logout, me)
│   │   ├── syntheses.ts               # Syntheses endpoints (list, get, generate, delete, email)
│   │   ├── sources.ts                 # Sources endpoints (list, add, delete, bulk, CSV)
│   │   ├── settings.ts               # Settings endpoints (get, update, export, import)
│   │   ├── admin.ts                   # Admin endpoints (providers, rate-limits, users)
│   │   └── config.ts                  # Public config endpoint (provider list for users)
│   │
│   ├── types/                          # TypeScript type definitions
│   │   ├── index.ts                    # Re-export barrel
│   │   ├── auth.ts                     # User, Session, RegisterRequest, LoginRequest
│   │   ├── synthesis.ts               # Synthesis, NewsSection, NewsItem, GenerationJob
│   │   ├── source.ts                  # Source, CreateSourceRequest, BulkImportRequest
│   │   ├── settings.ts               # UserSettings, DEFAULT_SETTINGS
│   │   ├── admin.ts                   # ProviderConfig, RateLimitConfig, AdminUser
│   │   └── api.ts                     # ApiError, PaginatedResponse, SSEEvent
│   │
│   ├── i18n/                           # Internationalization
│   │   ├── index.ts                    # i18n context provider and useI18n hook
│   │   ├── types.ts                    # Translation key type definitions
│   │   └── locales/
│   │       └── fr.ts                   # French translations (default)
│   │
│   ├── context/                        # SolidJS context providers
│   │   ├── AuthContext.tsx             # Auth state (user, session check, login/logout)
│   │   └── ToastContext.tsx            # Global toast notification system
│   │
│   ├── lib/                            # Utilities and helpers
│   │   ├── sse.ts                      # EventSource wrapper for SolidJS signals
│   │   ├── dates.ts                    # Date formatting (date-fns/fr wrappers)
│   │   ├── export.ts                   # PDF and Markdown generation utilities
│   │   ├── csv.ts                      # CSV parse/generate utilities
│   │   └── validation.ts              # Form validation rules and helpers
│   │
│   ├── components/                     # Shared/reusable UI components
│   │   ├── layout/
│   │   │   ├── AppShell.tsx            # Main layout: navbar + mobile menu + main content
│   │   │   ├── Navbar.tsx              # Top navigation bar with responsive menu
│   │   │   ├── MobileMenu.tsx          # Hamburger slide-out menu for mobile
│   │   │   └── AdminLayout.tsx         # Admin section layout with sidebar nav
│   │   │
│   │   ├── auth/
│   │   │   └── ProtectedRoute.tsx      # Route guard: redirects to /login if unauthenticated
│   │   │
│   │   ├── ui/
│   │   │   ├── Button.tsx              # Reusable button (variants: primary, secondary, danger)
│   │   │   ├── Card.tsx                # Card container with optional header/footer
│   │   │   ├── Spinner.tsx             # Loading spinner (inline and full-page variants)
│   │   │   ├── Badge.tsx               # Status/category badge
│   │   │   ├── ConfirmDialog.tsx       # Modal confirmation dialog (standardized deletion)
│   │   │   ├── Toast.tsx               # Toast notification component
│   │   │   ├── ErrorBanner.tsx         # Inline error message with icon
│   │   │   ├── SuccessBanner.tsx       # Inline success message with icon
│   │   │   ├── EmptyState.tsx          # Empty list placeholder with action
│   │   │   └── SSEProgressIndicator.tsx # Generation progress bar with step list
│   │   │
│   │   ├── forms/
│   │   │   ├── FormField.tsx           # Label + input + error message wrapper
│   │   │   ├── TextInput.tsx           # Text input with validation display
│   │   │   ├── TextArea.tsx            # Textarea with validation display
│   │   │   ├── Select.tsx              # Select dropdown
│   │   │   ├── NumberInput.tsx         # Number input with min/max
│   │   │   └── FileInput.tsx           # Styled file upload input
│   │   │
│   │   └── synthesis/
│   │       ├── SynthesisCard.tsx        # Card for synthesis list (Home page)
│   │       ├── SynthesisSection.tsx     # Section display in detail view
│   │       └── NewsItemCard.tsx         # Individual news item in detail view
│   │
│   └── pages/                          # Route-level page components
│       ├── auth/
│       │   ├── Login.tsx               # Email + Turnstile -> request magic link
│       │   ├── Register.tsx            # Email + display name + Turnstile -> create account
│       │   └── MagicLinkVerify.tsx     # Token verification redirect page
│       │
│       ├── Home.tsx                    # Dashboard: synthesis list + in-progress banner
│       ├── GenerateSynthesis.tsx       # Generation trigger + SSE progress display
│       ├── SynthesisDetail.tsx         # Full synthesis view + email + export + delete
│       ├── Sources.tsx                 # Sources CRUD + CSV import/export + bulk import
│       ├── Settings.tsx                # User settings (theme, categories, provider/model/key)
│       │
│       └── admin/
│           ├── ProviderCatalog.tsx      # Manage LLM providers and available models
│           ├── RateLimitConfig.tsx      # Configure global rate limits per provider
│           └── UserManagement.tsx       # List users, change roles

1.2 Package Dependencies

{
  "name": "ai-weekly-synth-frontend",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "solid-js": "^1.9.0",
    "@solidjs/router": "^0.15.0",
    "lucide-solid": "^0.475.0",
    "date-fns": "^4.1.0",
    "jspdf": "^2.5.2",
    "jspdf-autotable": "^3.8.4"
  },
  "devDependencies": {
    "typescript": "~5.8.0",
    "vite": "^6.2.0",
    "vite-plugin-solid": "^2.11.0",
    "@tailwindcss/vite": "^4.1.0",
    "tailwindcss": "^4.1.0",
    "vitest": "^3.0.0",
    "@solidjs/testing-library": "^0.8.0",
    "@testing-library/jest-dom": "^6.6.0",
    "jsdom": "^25.0.0"
  }
}

Dependency justifications:

Package Why
solid-js Core UI framework (per project decisions)
@solidjs/router Official SolidJS router with nested routes, guards, lazy loading
lucide-solid SolidJS-specific icon library -- same icon set as current lucide-react, preserves visual continuity
date-fns Framework-agnostic date formatting. Reuses current French locale formatting patterns
jspdf + jspdf-autotable Client-side PDF generation for synthesis export. No server round-trip needed
vite-plugin-solid Vite plugin for SolidJS JSX compilation
@tailwindcss/vite Tailwind CSS v4 Vite integration (replaces PostCSS plugin)
@solidjs/testing-library Official testing utilities for SolidJS components
vitest Vite-native test runner, zero config with Vite projects

Notable exclusions:

  • No @google/genai -- LLM calls move to the Rust backend.
  • No firebase -- replaced by REST API + session cookies.
  • No react, react-dom, react-router-dom -- replaced by SolidJS equivalents.
  • No turnstile-react or similar -- Cloudflare Turnstile is loaded via script tag and used imperatively.
  • No state management library (e.g., zustand) -- SolidJS signals and stores are sufficient.

1.3 Vite Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [
    solid(),
    tailwindcss(),
  ],
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  build: {
    target: 'esnext',
    outDir: 'dist',
  },
});

Key points:

  • vite-plugin-solid handles SolidJS JSX transformation (converts JSX to createComponent / template calls).
  • @tailwindcss/vite replaces the old PostCSS-based Tailwind pipeline.
  • Dev proxy maps /api/* to the Rust backend at localhost:8080, avoiding CORS issues during development.
  • Production: the frontend is served as static files by the Rust backend (or an nginx container in Docker Compose).

1.4 TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

Critical SolidJS setting: "jsxImportSource": "solid-js" tells TypeScript to use SolidJS's JSX types instead of React's. This ensures class (not className), SolidJS event types, and SolidJS component return types.


2. Component Architecture

2.1 Layout Components

src/App.tsx -- Root Component

Purpose: Wire up providers (Auth, Toast, i18n) and the router. Replaces the current App.tsx which nests AuthProvider > Router > Routes.

SolidJS pattern: Router from @solidjs/router wraps the entire app. Providers are nested above the router.

// src/App.tsx
import { Component } from 'solid-js';
import { Router, Route } from '@solidjs/router';
import { AuthProvider } from '~/context/AuthContext';
import { ToastProvider } from '~/context/ToastContext';
import { I18nProvider } from '~/i18n';

// Lazy-loaded pages
import { lazy } from 'solid-js';
const Home = lazy(() => import('~/pages/Home'));
const Login = lazy(() => import('~/pages/auth/Login'));
const Register = lazy(() => import('~/pages/auth/Register'));
const MagicLinkVerify = lazy(() => import('~/pages/auth/MagicLinkVerify'));
const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis'));
const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail'));
const Sources = lazy(() => import('~/pages/Sources'));
const Settings = lazy(() => import('~/pages/Settings'));
const ProviderCatalog = lazy(() => import('~/pages/admin/ProviderCatalog'));
const RateLimitConfig = lazy(() => import('~/pages/admin/RateLimitConfig'));
const UserManagement = lazy(() => import('~/pages/admin/UserManagement'));

const App: Component = () => {
  return (
    <I18nProvider locale="fr">
      <ToastProvider>
        <AuthProvider>
          <Router>
            {/* Public routes */}
            <Route path="/login" component={Login} />
            <Route path="/register" component={Register} />
            <Route path="/auth/verify" component={MagicLinkVerify} />

            {/* Protected routes wrapped in AppShell */}
            <Route path="/" component={ProtectedAppShell}>
              <Route path="/" component={Home} />
              <Route path="/generate" component={GenerateSynthesis} />
              <Route path="/synthesis/:id" component={SynthesisDetail} />
              <Route path="/sources" component={Sources} />
              <Route path="/settings" component={Settings} />
            </Route>

            {/* Admin routes wrapped in AdminLayout */}
            <Route path="/admin" component={ProtectedAdminShell}>
              <Route path="/providers" component={ProviderCatalog} />
              <Route path="/rate-limits" component={RateLimitConfig} />
              <Route path="/users" component={UserManagement} />
            </Route>
          </Router>
        </AuthProvider>
      </ToastProvider>
    </I18nProvider>
  );
};

export default App;

React pattern replaced: BrowserRouter > Routes > Route with per-route <ProtectedRoute><Layout> wrappers. SolidJS uses nested <Route> with layout components directly in the hierarchy.


src/components/layout/AppShell.tsx -- Main Layout

Purpose: Wraps all authenticated (non-admin) pages. Renders Navbar + main content area.

Props/Signals:

  • Reads user from AuthContext
  • Reads current route from useLocation() for active nav highlighting
  • mobileMenuOpen: Signal<boolean> for hamburger toggle

SolidJS patterns: <Show> for conditional rendering, children via props.children (not {children} destructure -- SolidJS requires props.children to maintain reactivity).

import { Component, Show, createSignal } from 'solid-js';
import { useAuth } from '~/context/AuthContext';
import Navbar from './Navbar';
import MobileMenu from './MobileMenu';

const AppShell: Component = (props) => {
  const { user } = useAuth();
  const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);

  return (
    <div class="min-h-screen bg-gray-50">
      <Navbar
        user={user()}
        onToggleMobile={() => setMobileMenuOpen(!mobileMenuOpen())}
      />
      <Show when={mobileMenuOpen()}>
        <MobileMenu onClose={() => setMobileMenuOpen(false)} />
      </Show>
      <main>
        {props.children}
      </main>
    </div>
  );
};

Key SolidJS difference: In React, {children} is destructured from props. In SolidJS, always access as props.children to preserve reactivity tracking. Destructuring breaks the reactive subscription.


src/components/layout/Navbar.tsx -- Navigation Bar

Purpose: Top navigation bar with logo, nav links, user info, settings icon, admin dropdown (if admin), logout. Includes mobile hamburger button.

Props:

interface NavbarProps {
  user: User | null;
  onToggleMobile: () => void;
}

SolidJS patterns:

  • useLocation() from @solidjs/router for active route detection
  • <A> component from @solidjs/router (replaces React Router's <Link>)
  • <Show when={user()?.role === 'admin'}> for admin nav items
  • class attribute (not className)

Active route indicator: Compute active class with:

const location = useLocation();
const isActive = (path: string) =>
  location.pathname === path
    ? 'border-indigo-500 text-gray-900'
    : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700';

React pattern replaced: Current Layout component in App.tsx. The current app has no active route indicator and no mobile menu -- both are added in this rewrite.


src/components/layout/MobileMenu.tsx -- Mobile Navigation

Purpose: Slide-out mobile menu (hamburger), fixing the current app's missing mobile navigation. Renders nav links + user info + logout.

Props:

interface MobileMenuProps {
  onClose: () => void;
}

SolidJS patterns: onCleanup to add/remove click-outside handler. <For> to iterate nav items.


src/components/layout/AdminLayout.tsx -- Admin Section Layout

Purpose: Layout for /admin/* routes. Includes sidebar with links to provider catalog, rate limits, user management. Wraps admin pages.

SolidJS patterns: Nested <Route> children via props.children. Active sidebar link via useLocation().


src/components/auth/ProtectedRoute.tsx -- Route Guard

Purpose: Checks auth state. If unauthenticated, redirects to /login. If loading, shows full-page spinner.

SolidJS patterns:

  • Reads user and loading from AuthContext
  • Uses <Show> with fallback for the loading state
  • Uses <Navigate> from @solidjs/router for redirect
import { Component, Show } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { useAuth } from '~/context/AuthContext';
import Spinner from '~/components/ui/Spinner';

const ProtectedRoute: Component = (props) => {
  const { user, loading } = useAuth();

  return (
    <Show
      when={!loading()}
      fallback={<Spinner fullPage />}
    >
      <Show
        when={user()}
        fallback={<Navigate href="/login" />}
      >
        {props.children}
      </Show>
    </Show>
  );
};

React pattern replaced: Current ProtectedRoute uses if (loading) return ... and if (!user) return <Navigate>. SolidJS uses nested <Show> components instead of early returns.


2.2 Auth Pages

Purpose: Email field + Cloudflare Turnstile captcha + submit to request magic link. Shows confirmation message after submit with resend button.

Signals:

const [email, setEmail] = createSignal('');
const [turnstileToken, setTurnstileToken] = createSignal<string | null>(null);
const [submitted, setSubmitted] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [resendCooldown, setResendCooldown] = createSignal(0);

API calls:

  • POST /api/v1/auth/login with { email, captcha_token }

SolidJS patterns:

  • onMount to load the Turnstile script if not already loaded
  • createEffect to manage resend cooldown timer (countdown from 60s)
  • onCleanup to clear the timer interval
  • <Show when={submitted()}> to toggle between form view and confirmation view

Turnstile integration: Load via <script> tag. Render into a <div ref={turnstileRef}> using window.turnstile.render(). Callback sets the turnstileToken signal.

let turnstileRef: HTMLDivElement | undefined;

onMount(() => {
  if (!document.getElementById('cf-turnstile-script')) {
    const script = document.createElement('script');
    script.id = 'cf-turnstile-script';
    script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
    script.onload = () => renderTurnstile();
    document.head.appendChild(script);
  } else {
    renderTurnstile();
  }
});

const renderTurnstile = () => {
  if (turnstileRef && window.turnstile) {
    window.turnstile.render(turnstileRef, {
      sitekey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
      callback: (token: string) => setTurnstileToken(token),
    });
  }
};

React pattern replaced: Current Login component uses a single Google sign-in button. The new version is a form-based flow with captcha.


src/pages/auth/Register.tsx -- Account Creation

Purpose: Email + optional display name + Turnstile captcha. Submit sends registration request, then shows confirmation to check email.

Signals: Similar to Login, plus displayName: Signal<string>.

API calls:

  • POST /api/v1/auth/register with { email, display_name, captcha_token }

SolidJS patterns: Same as Login. Shared Turnstile loading logic can be extracted to lib/turnstile.ts helper.


src/pages/auth/MagicLinkVerify.tsx -- Token Verification

Purpose: User arrives via magic link email (/auth/verify?token=...). Extracts token from URL, calls backend to verify, then redirects to Home on success.

Signals:

const [status, setStatus] = createSignal<'verifying' | 'success' | 'error'>('verifying');
const [errorMessage, setErrorMessage] = createSignal('');

API calls:

  • GET /api/v1/auth/verify?token=<token> -- sets session cookie in response

SolidJS patterns:

  • useSearchParams() from @solidjs/router to extract the token query parameter
  • onMount to trigger the verification call immediately
  • useNavigate() to redirect to / on success after a brief delay
const MagicLinkVerify: Component = () => {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const [status, setStatus] = createSignal<'verifying' | 'success' | 'error'>('verifying');

  onMount(async () => {
    const token = searchParams.token;
    if (!token) {
      setStatus('error');
      return;
    }
    try {
      await authApi.verify(token as string);
      setStatus('success');
      setTimeout(() => navigate('/', { replace: true }), 1500);
    } catch (e) {
      setStatus('error');
    }
  });

  return (
    <div class="min-h-screen flex items-center justify-center bg-gray-50">
      <Switch>
        <Match when={status() === 'verifying'}>
          <Spinner fullPage />
        </Match>
        <Match when={status() === 'success'}>
          <SuccessBanner message={t('auth.verifySuccess')} />
        </Match>
        <Match when={status() === 'error'}>
          <ErrorBanner message={t('auth.verifyError')} />
        </Match>
      </Switch>
    </div>
  );
};

2.3 Main Pages

src/pages/Home.tsx -- Synthesis List (Dashboard)

Purpose: Display a grid of synthesis cards, ordered by creation date. Show "New Synthesis" button. Show in-progress banner if a generation is running.

Signals:

const [syntheses] = createResource(fetchSyntheses);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
const [activeJob, setActiveJob] = createSignal<GenerationJob | null>(null);

API calls:

  • GET /api/v1/syntheses via createResource for initial load
  • SSE connection for real-time list updates (new synthesis appears, deletion reflects)
  • DELETE /api/v1/syntheses/:id
  • Check for in-progress generation jobs on mount

SolidJS patterns:

  • createResource replaces useState + useEffect for async data fetching. It provides .loading, .error, and .latest properties.
  • <For each={syntheses()}> replaces {array.map(...)}. <For> is optimized for keyed list rendering without re-creating DOM nodes.
  • <Show when={syntheses()?.length === 0}> for empty state.
  • <Show when={activeJob()}> for in-progress generation banner.
  • SSE listener via createEffect + onCleanup to mutate the resource.
const Home: Component = () => {
  const { t } = useI18n();
  const [syntheses, { mutate, refetch }] = createResource(
    () => synthesesApi.list()
  );
  const [deletingId, setDeletingId] = createSignal<string | null>(null);

  // SSE for real-time updates
  createEffect(() => {
    const eventSource = createSSEConnection('/api/v1/syntheses/events');
    eventSource.on('created', (data) => {
      mutate((prev) => prev ? [data, ...prev] : [data]);
    });
    eventSource.on('deleted', (data) => {
      mutate((prev) => prev?.filter((s) => s.id !== data.id) ?? []);
    });
    onCleanup(() => eventSource.close());
  });

  const handleDelete = async (id: string) => {
    // Show confirm dialog, then call API
    await synthesesApi.delete(id);
    mutate((prev) => prev?.filter((s) => s.id !== id) ?? []);
  };

  return (
    <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      {/* Header with title + New Synthesis button */}
      {/* In-progress banner */}
      <Show when={!syntheses.loading} fallback={<Spinner />}>
        <Show
          when={syntheses()?.length}
          fallback={<EmptyState ... />}
        >
          <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
            <For each={syntheses()}>
              {(synth) => (
                <SynthesisCard
                  synthesis={synth}
                  isDeleting={deletingId() === synth.id}
                  onDelete={() => handleDelete(synth.id)}
                />
              )}
            </For>
          </div>
        </Show>
      </Show>
    </div>
  );
};

React pattern replaced:

  • useState + useEffect with Firestore onSnapshot --> createResource + SSE via createEffect
  • {array.map()} --> <For each={...}>
  • className --> class

src/pages/GenerateSynthesis.tsx -- Generation with SSE Progress

Purpose: Show generation parameters (from user settings). Trigger generation. Display real-time SSE progress (step list + progress bar). Handle navigation-away (generation continues in background).

Signals:

const [settings] = createResource(settingsApi.get);
const [jobId, setJobId] = createSignal<string | null>(null);
const [progress, setProgress] = createSignal<SSEProgressEvent | null>(null);
const [status, setStatus] = createSignal<'idle' | 'generating' | 'complete' | 'error'>('idle');
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);

API calls:

  • GET /api/v1/settings via createResource (to show "theme" and "maxAgeDays" in the trigger confirmation text)
  • POST /api/v1/syntheses/generate to start generation (returns job_id)
  • SSE: GET /api/v1/syntheses/generate/:job_id/progress for progress events

SolidJS patterns:

  • createEffect to open SSE connection when jobId() is set
  • onCleanup to close SSE on unmount (but generation continues server-side)
  • <Switch>/<Match> for status-based rendering (idle/generating/complete/error)
  • <Show> for conditional progress display
const handleGenerate = async () => {
  setStatus('generating');
  try {
    const { job_id } = await synthesesApi.generate();
    setJobId(job_id);
  } catch (e) {
    setStatus('error');
    setErrorMessage(parseApiError(e));
  }
};

// SSE progress tracking
createEffect(() => {
  const id = jobId();
  if (!id) return;

  const sse = createSSEConnection(`/api/v1/syntheses/generate/${id}/progress`);
  sse.on('progress', (data) => setProgress(data));
  sse.on('complete', (data) => {
    setStatus('complete');
    setTimeout(() => navigate(`/synthesis/${data.synthesis_id}`), 1500);
  });
  sse.on('error', (data) => {
    setStatus('error');
    setErrorMessage(data.message);
  });
  onCleanup(() => sse.close());
});

Progress UI: The SSEProgressIndicator component renders a progress bar and step list:

// SSEProgressIndicator props
interface SSEProgressIndicatorProps {
  progress: SSEProgressEvent | null;
}

// SSEProgressEvent type
interface SSEProgressEvent {
  step: 'search' | 'scraping' | 'rewrite' | 'saving';
  message: string;
  percent: number;
}

React pattern replaced: Current GenerateSynthesis calls generateWeeklySynthesis() directly from the browser (Gemini API). The new version triggers a backend job and monitors via SSE. The single spinner is replaced by a multi-step progress indicator.


src/pages/SynthesisDetail.tsx -- Reading View + Email + Export + Delete

Purpose: Display full synthesis (sections with news items). Provide email sending, PDF export, Markdown export, and deletion.

Signals:

const [synthesis] = createResource(
  () => params.id,
  (id) => synthesesApi.get(id)
);
const [email, setEmail] = createSignal('');
const [sendingEmail, setSendingEmail] = createSignal(false);
const [emailSuccess, setEmailSuccess] = createSignal(false);
const [emailError, setEmailError] = createSignal<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
const [isDeleting, setIsDeleting] = createSignal(false);

API calls:

  • GET /api/v1/syntheses/:id via createResource with params.id as source
  • POST /api/v1/syntheses/:id/email with { recipient_email }
  • DELETE /api/v1/syntheses/:id

SolidJS patterns:

  • createResource with a source signal (params.id) -- re-fetches when the param changes
  • useParams() from @solidjs/router
  • <For each={synthesis()?.sections}> to render sections
  • <Show when={showDeleteConfirm()}> for delete confirmation (standardized ConfirmDialog)

Export actions:

  • "Export PDF" button calls generatePdf(synthesis()) from lib/export.ts
  • "Export Markdown" button calls generateMarkdown(synthesis()) from lib/export.ts
  • Email pre-fills with user's own email from AuthContext

React pattern replaced:

  • useParams<{ id: string }>() --> useParams() (SolidJS version returns a reactive proxy, not a plain object)
  • useEffect([id]) with Firestore onSnapshot --> createResource with params.id as reactive source
  • Gmail API email sending --> POST /api/v1/syntheses/:id/email backend call
  • Legacy data fallback rendering removed (per decisions: clean sections[] only)

src/pages/Sources.tsx -- Sources Management

Purpose: CRUD for custom sources. Single add form, CSV import/export, bulk text import. List of existing sources with delete.

Signals:

const [sources, { mutate, refetch }] = createResource(sourcesApi.list);
const [newTitle, setNewTitle] = createSignal('');
const [newUrl, setNewUrl] = createSignal('');
const [adding, setAdding] = createSignal(false);
const [bulkText, setBulkText] = createSignal('');
const [importing, setImporting] = createSignal(false);
const [importError, setImportError] = createSignal<string | null>(null);

API calls:

  • GET /api/v1/sources via createResource
  • POST /api/v1/sources to add a single source
  • DELETE /api/v1/sources/:id to delete
  • POST /api/v1/sources/bulk for bulk import (JSON array)
  • POST /api/v1/sources/import-csv for CSV file import (multipart)
  • GET /api/v1/sources/export-csv for CSV download

SolidJS patterns:

  • <For each={sources()}> for the source list
  • Optimistic UI: mutate() to add/remove from local state immediately, refetch() on error to resync
  • <Show when={importError()}> for error display
  • Form onSubmit handlers with preventDefault()

Deletion pattern change: Current app has no delete confirmation on sources. The new version uses the standardized ConfirmDialog component for consistency.

React pattern replaced:

  • useEffect with Firestore onSnapshot --> createResource with SSE for real-time (or refetch after mutations)
  • e: React.FormEvent --> native SubmitEvent (SolidJS does not have synthetic events)
  • e: React.ChangeEvent<HTMLInputElement> --> native Event with (e.target as HTMLInputElement).files

src/pages/Settings.tsx -- User Settings

Purpose: Configure theme, search window, categories (dynamic list), AI provider/model/API key, search agent behavior. Import/export JSON.

Signals:

const [settings, { mutate }] = createResource(settingsApi.get);
const [localSettings, setLocalSettings] = createSignal<UserSettings | null>(null);
const [saving, setSaving] = createSignal(false);
const [message, setMessage] = createSignal<{ type: 'success' | 'error'; text: string } | null>(null);
const [providers] = createResource(configApi.getProviders);

API calls:

  • GET /api/v1/settings via createResource
  • PUT /api/v1/settings to save
  • GET /api/v1/settings/export for JSON download
  • POST /api/v1/settings/import for JSON upload
  • GET /api/v1/config/providers to get available providers and models (admin-curated list)

SolidJS patterns:

  • createEffect to initialize localSettings from settings() when loaded
  • createStore for form state (deep nested updates for categories array)
  • <For each={localSettings()?.categories}> for the dynamic category list
  • <Show when={providers()?.length > 1}> to conditionally show provider dropdown
  • Cascading dropdowns: when provider changes, model list updates

New fields compared to current app:

  • Provider dropdown: <Select> populated from GET /api/v1/config/providers. Hidden if only one provider configured.
  • Model dropdown: Dynamically populated based on selected provider.
  • API Key field: Masked input with show/hide toggle. User provides their own key.

Category management:

const [settingsStore, setSettingsStore] = createStore<UserSettings>(DEFAULT_SETTINGS);

// Add category
const addCategory = () => {
  if (settingsStore.categories.length >= 20) return;
  setSettingsStore('categories', settingsStore.categories.length, t('settings.newCategory'));
};

// Remove category
const removeCategory = (index: number) => {
  if (settingsStore.categories.length <= 1) return;
  setSettingsStore('categories', (cats) => cats.filter((_, i) => i !== index));
};

// Update category
const updateCategory = (index: number, value: string) => {
  setSettingsStore('categories', index, value);
};

React pattern replaced:

  • useState<AppSettings> with spread-based updates --> createStore<UserSettings> with path-based updates
  • Hardcoded AI model <option> values --> dynamic model list from backend GET /api/v1/config/providers
  • No provider selection --> two-level provider + model selection

2.4 Admin Pages

src/pages/admin/ProviderCatalog.tsx -- LLM Provider Management

Purpose: Admin configures available LLM providers. For each provider: name, list of available models, default model, enabled/disabled toggle.

Note: Per decisions, users bring their own API keys. The admin only curates which providers/models are available.

Signals:

const [providers, { refetch }] = createResource(adminApi.getProviders);
const [editingProvider, setEditingProvider] = createSignal<ProviderConfig | null>(null);
const [saving, setSaving] = createSignal(false);

API calls:

  • GET /api/v1/admin/providers
  • POST /api/v1/admin/providers (add/update)
  • DELETE /api/v1/admin/providers/:id

SolidJS patterns:

  • <For each={providers()}> for provider cards/tabs
  • <Show when={editingProvider()}> for edit form
  • createStore for the edit form state (nested model array)

src/pages/admin/RateLimitConfig.tsx -- Rate Limit Configuration

Purpose: Configure global rate limits per provider (requests/minute). Show current defaults.

Signals:

const [rateLimits, { refetch }] = createResource(adminApi.getRateLimits);
const [localLimits, setLocalLimits] = createStore<Record<string, RateLimitConfig>>({});
const [saving, setSaving] = createSignal(false);

API calls:

  • GET /api/v1/admin/rate-limits
  • PUT /api/v1/admin/rate-limits/:provider_id

src/pages/admin/UserManagement.tsx -- User List and Roles

Purpose: List all registered users. Allow admin to promote/demote user roles.

Signals:

const [users] = createResource(adminApi.listUsers);

API calls:

  • GET /api/v1/admin/users
  • PUT /api/v1/admin/users/:id/role

2.5 Shared UI Components

src/components/ui/Button.tsx

import { Component, JSX, Show, splitProps } from 'solid-js';
import { Loader2 } from 'lucide-solid';

interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  loading?: boolean;
  icon?: Component<{ class?: string }>;
}

const variantClasses = {
  primary: 'text-white bg-indigo-600 hover:bg-indigo-700 border-transparent focus:ring-indigo-500',
  secondary: 'text-gray-700 bg-white hover:bg-gray-50 border-gray-300 focus:ring-indigo-500',
  danger: 'text-white bg-red-600 hover:bg-red-700 border-transparent focus:ring-red-500',
};

const Button: Component<ButtonProps> = (allProps) => {
  const [props, rest] = splitProps(allProps, ['variant', 'loading', 'icon', 'children']);
  const variant = () => props.variant ?? 'primary';

  return (
    <button
      {...rest}
      disabled={rest.disabled || props.loading}
      class={`inline-flex items-center justify-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant()]} ${rest.class ?? ''}`}
    >
      <Show when={props.loading} fallback={
        <Show when={props.icon}>
          {(Icon) => <Icon class="-ml-1 mr-2 h-5 w-5" />}
        </Show>
      }>
        <Loader2 class="animate-spin -ml-1 mr-2 h-5 w-5" />
      </Show>
      {props.children}
    </button>
  );
};

SolidJS pattern: splitProps separates custom props from native HTML attributes, allowing safe spreading of rest onto the DOM element. This replaces the React pattern of destructuring plus ...rest.


src/components/ui/ConfirmDialog.tsx

interface ConfirmDialogProps {
  open: boolean;
  title: string;
  message: string;
  confirmLabel?: string;
  cancelLabel?: string;
  variant?: 'danger' | 'warning';
  loading?: boolean;
  onConfirm: () => void;
  onCancel: () => void;
}

SolidJS patterns:

  • <Show when={props.open}> to mount/unmount
  • <Portal> from solid-js/web to render at document root (proper overlay stacking)
  • Focus trap and keyboard handling via createEffect + onCleanup

src/components/ui/Spinner.tsx

interface SpinnerProps {
  fullPage?: boolean;
  size?: 'sm' | 'md' | 'lg';
}

Preserves the current indigo spinner style: animate-spin rounded-full border-b-2 border-indigo-600.


src/components/ui/SSEProgressIndicator.tsx

interface SSEProgressIndicatorProps {
  progress: SSEProgressEvent | null;
  steps: Array<{ key: string; label: string }>;
}

Renders:

  • A progress bar (0-100%) with indigo fill
  • A step list with status indicators (done checkmark, in-progress spinner, pending gray)
  • Current step description text

src/components/ui/Toast.tsx and src/context/ToastContext.tsx

Global toast notification system. Replaces the scattered success/error inline messages with a unified notification system.

// Toast types
interface Toast {
  id: string;
  type: 'success' | 'error' | 'info';
  message: string;
  duration?: number; // ms, default 5000
}

// Context
interface ToastContextType {
  addToast: (toast: Omit<Toast, 'id'>) => void;
  removeToast: (id: string) => void;
}

SolidJS patterns:

  • createContext + useContext for global access
  • createStore<Toast[]> for the toast queue
  • <For each={toasts}> to render active toasts
  • <Portal> to render at document root (top-right corner)
  • Auto-dismiss via setTimeout in createEffect, cleaned up with onCleanup

src/components/ui/ErrorBanner.tsx and src/components/ui/SuccessBanner.tsx

Inline feedback components preserving the current visual style:

  • Error: red background, left border, AlertCircle icon
  • Success: green background, left border, CheckCircle2 icon
interface BannerProps {
  message: string;
}

src/components/ui/EmptyState.tsx

Reusable empty state placeholder with icon, title, description, and optional CTA button.

interface EmptyStateProps {
  icon: Component;
  title: string;
  description: string;
  actionLabel?: string;
  actionHref?: string;
  onAction?: () => void;
}

src/components/synthesis/SynthesisCard.tsx

Extracted from current Home.tsx inline card rendering. Displays week badge, creation date, first section preview, "read" link, delete button.

interface SynthesisCardProps {
  synthesis: Synthesis;
  onDelete: () => void;
}

src/components/synthesis/SynthesisSection.tsx

Extracted from current SynthesisDetail.tsx Section component. Renders a section title with border and list of NewsItemCards.

interface SynthesisSectionProps {
  title: string;
  items: NewsItem[];
}

src/components/synthesis/NewsItemCard.tsx

Individual news item: title as external link, summary paragraph.

interface NewsItemCardProps {
  item: NewsItem;
}

3. State Management

3.1 Auth State -- src/context/AuthContext.tsx

The auth context is the most critical global state. It replaces Firebase's onAuthStateChanged with a session check on app load.

import { createContext, useContext, createSignal, onMount, ParentComponent } from 'solid-js';
import { authApi } from '~/api/auth';
import type { User } from '~/types/auth';

interface AuthContextType {
  user: () => User | null;
  loading: () => boolean;
  isAdmin: () => boolean;
  login: (email: string, captchaToken: string) => Promise<void>;
  register: (email: string, displayName: string, captchaToken: string) => Promise<void>;
  logout: () => Promise<void>;
  refreshUser: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType>();

export const AuthProvider: ParentComponent = (props) => {
  const [user, setUser] = createSignal<User | null>(null);
  const [loading, setLoading] = createSignal(true);

  const isAdmin = () => user()?.role === 'admin';

  // Check session on app load
  onMount(async () => {
    try {
      const currentUser = await authApi.me();
      setUser(currentUser);
    } catch {
      setUser(null);
    } finally {
      setLoading(false);
    }
  });

  const login = async (email: string, captchaToken: string) => {
    await authApi.login({ email, captcha_token: captchaToken });
    // Does not set user -- magic link flow. User will be set after verify.
  };

  const register = async (email: string, displayName: string, captchaToken: string) => {
    await authApi.register({ email, display_name: displayName, captcha_token: captchaToken });
  };

  const logout = async () => {
    await authApi.logout();
    setUser(null);
  };

  const refreshUser = async () => {
    try {
      const currentUser = await authApi.me();
      setUser(currentUser);
    } catch {
      setUser(null);
    }
  };

  return (
    <AuthContext.Provider value={{ user, loading, isAdmin, login, register, logout, refreshUser }}>
      {props.children}
    </AuthContext.Provider>
  );
};

export const useAuth = (): AuthContextType => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
};

Key difference from React: The user and loading are accessor functions (SolidJS signals), not plain values. Consumers call user() and loading() to read them. This enables fine-grained reactivity -- only the specific DOM nodes that read user() will update when it changes, not the entire component tree.

3.2 When to Use Each State Primitive

Primitive When to use Example
createSignal<T> Simple, local component state. Single value. const [email, setEmail] = createSignal('')
createStore<T> Complex, nested objects or arrays. When you need path-based updates. Settings form, categories list, admin provider config
createResource<T> Async data fetching tied to a reactive source. Provides .loading, .error. API calls: fetch syntheses, fetch settings, etc.
createContext Global state shared across many components. Rarely changes (auth, i18n, theme). AuthContext, ToastContext, I18nContext
createEffect Side effects that react to signal changes. Replaces useEffect. SSE connections, timers, DOM manipulation
createMemo Derived computed values (cached). Filtered/sorted lists, computed display strings

3.3 Global vs. Local State

Global (via Context):

  • Auth state (user, loading, isAdmin) -- needed in Navbar, ProtectedRoute, every page
  • Toast notifications -- triggered from any component
  • i18n translations -- accessed in every component

Local (via createSignal/createStore):

  • Form state (Settings, Sources add form, Login form)
  • Page-specific loading/error state
  • UI state (mobileMenuOpen, showDeleteConfirm, deletingId)
  • SSE progress state (only relevant to GenerateSynthesis page)

3.4 API Client Abstraction -- src/api/client.ts

A centralized fetch wrapper that handles session cookies, CSRF headers, error parsing, and 401 redirect.

// src/api/client.ts

const API_BASE = '/api/v1';

interface ApiError {
  status: number;
  message: string;
  fieldErrors?: Record<string, string>;
}

class ApiClient {
  private async request<T>(
    method: string,
    path: string,
    options?: {
      body?: unknown;
      headers?: Record<string, string>;
      signal?: AbortSignal;
    }
  ): Promise<T> {
    const url = `${API_BASE}${path}`;
    const headers: Record<string, string> = {
      'X-Requested-With': 'XMLHttpRequest', // CSRF protection
      ...options?.headers,
    };

    if (options?.body && !(options.body instanceof FormData)) {
      headers['Content-Type'] = 'application/json';
    }

    const response = await fetch(url, {
      method,
      headers,
      body: options?.body instanceof FormData
        ? options.body
        : options?.body
        ? JSON.stringify(options.body)
        : undefined,
      credentials: 'same-origin', // Send session cookie
      signal: options?.signal,
    });

    if (!response.ok) {
      if (response.status === 401) {
        // Session expired -- redirect to login
        window.location.href = '/login';
        throw new Error('Session expired');
      }
      const errorBody = await response.json().catch(() => ({ error: 'Unknown error' }));
      const apiError: ApiError = {
        status: response.status,
        message: errorBody.error || `HTTP ${response.status}`,
        fieldErrors: errorBody.field_errors,
      };
      throw apiError;
    }

    // Handle empty responses (204 No Content)
    if (response.status === 204) {
      return undefined as T;
    }

    return response.json();
  }

  get<T>(path: string, signal?: AbortSignal): Promise<T> {
    return this.request<T>('GET', path, { signal });
  }

  post<T>(path: string, body?: unknown): Promise<T> {
    return this.request<T>('POST', path, { body });
  }

  put<T>(path: string, body?: unknown): Promise<T> {
    return this.request<T>('PUT', path, { body });
  }

  delete<T>(path: string): Promise<T> {
    return this.request<T>('DELETE', path);
  }

  upload<T>(path: string, formData: FormData): Promise<T> {
    return this.request<T>('POST', path, { body: formData });
  }

  downloadBlob(path: string): Promise<Blob> {
    return fetch(`${API_BASE}${path}`, {
      headers: { 'X-Requested-With': 'XMLHttpRequest' },
      credentials: 'same-origin',
    }).then((r) => {
      if (!r.ok) throw new Error(`Download failed: ${r.status}`);
      return r.blob();
    });
  }
}

export const api = new ApiClient();

CSRF protection: The X-Requested-With: XMLHttpRequest header is sent with every request. Combined with SameSite=Lax cookies, this prevents CSRF attacks (per the project's decision to use the Architect's simpler CSRF approach).

401 handling: On any 401 response, the client redirects to /login. This handles session expiry gracefully.

Domain-specific API modules (e.g., src/api/syntheses.ts) wrap the client:

// src/api/syntheses.ts
import { api } from './client';
import type { Synthesis, GenerateResponse } from '~/types/synthesis';

export const synthesesApi = {
  list: () => api.get<Synthesis[]>('/syntheses'),
  get: (id: string) => api.get<Synthesis>(`/syntheses/${id}`),
  generate: () => api.post<GenerateResponse>('/syntheses/generate'),
  delete: (id: string) => api.delete(`/syntheses/${id}`),
  sendEmail: (id: string, recipientEmail: string) =>
    api.post(`/syntheses/${id}/email`, { recipient_email: recipientEmail }),
};

4. Routing

4.1 Router Configuration

Using @solidjs/router (file-based or config-based). The configuration is in App.tsx (shown in Section 2.1).

4.2 Route Definitions

Path Component Guard Description
/login Login Public (redirect to / if logged in) Magic link request
/register Register Public (redirect to / if logged in) Account creation
/auth/verify MagicLinkVerify Public Token verification from magic link
/ Home Authenticated Synthesis list dashboard
/generate GenerateSynthesis Authenticated Trigger generation
/synthesis/:id SynthesisDetail Authenticated Read synthesis detail
/sources Sources Authenticated Manage custom sources
/settings Settings Authenticated User settings
/admin/providers ProviderCatalog Admin LLM provider config
/admin/rate-limits RateLimitConfig Admin Rate limit settings
/admin/users UserManagement Admin User management

4.3 Route Guards

Authenticated guard (ProtectedRoute): Wraps authenticated route groups as a layout. Checks useAuth().user(). Redirects to /login if null after loading.

Admin guard (ProtectedAdminShell): Same as authenticated plus checks useAuth().isAdmin(). Redirects to / if not admin.

// ProtectedAppShell -- authenticated routes layout
const ProtectedAppShell: Component = (props) => {
  const { user, loading } = useAuth();

  return (
    <Show when={!loading()} fallback={<Spinner fullPage />}>
      <Show when={user()} fallback={<Navigate href="/login" />}>
        <AppShell>{props.children}</AppShell>
      </Show>
    </Show>
  );
};

// ProtectedAdminShell -- admin routes layout
const ProtectedAdminShell: Component = (props) => {
  const { user, loading, isAdmin } = useAuth();

  return (
    <Show when={!loading()} fallback={<Spinner fullPage />}>
      <Show when={user() && isAdmin()} fallback={<Navigate href="/" />}>
        <AdminLayout>{props.children}</AdminLayout>
      </Show>
    </Show>
  );
};

4.4 Navigation Patterns

Programmatic navigation:

const navigate = useNavigate();
// After successful generation
navigate(`/synthesis/${data.synthesis_id}`);
// After deletion
navigate('/');
// After login form submit (no navigation -- user checks email)

Link components: Use <A> from @solidjs/router (not <Link>):

import { A } from '@solidjs/router';
<A href="/">Syntheses</A>
<A href={`/synthesis/${synth.id}`}>Lire la synthese</A>
  1. User clicks magic link in email: https://app.example.com/auth/verify?token=abc123
  2. MagicLinkVerify page loads, extracts token from useSearchParams()
  3. Calls GET /api/v1/auth/verify?token=abc123
  4. Backend verifies token, creates session, sets Set-Cookie header in response
  5. Frontend receives success, calls refreshUser() on AuthContext to load the user
  6. Navigates to / with replace: true (replaces history entry so back button does not re-verify)

5. i18n Architecture

5.1 Structure

i18n is implemented as a SolidJS context with typed translation keys and a simple lookup function. No external i18n library is needed for the MVP (French-only), but the structure supports adding languages later.

5.2 Translation File Format

// src/i18n/locales/fr.ts
const fr = {
  // Navigation
  'nav.syntheses': 'Syntheses',
  'nav.sources': 'Sources personnalisees',
  'nav.settings': 'Parametres',
  'nav.admin': 'Administration',
  'nav.logout': 'Deconnexion',

  // Auth
  'auth.loginTitle': 'AI Weekly Synth',
  'auth.loginSubtitle': 'Votre synthese hebdomadaire des actualites IA',
  'auth.emailPlaceholder': 'adresse@email.com',
  'auth.requestLink': 'Recevoir un lien de connexion',
  'auth.noAccount': 'Pas encore de compte ?',
  'auth.createAccount': 'Creer un compte',
  'auth.hasAccount': 'Deja un compte ?',
  'auth.signIn': 'Se connecter',
  'auth.linkSent': 'Un lien de connexion vous a ete envoye a {email}.',
  'auth.resendLink': 'Renvoyer le lien',
  'auth.resendIn': 'Renvoyer dans {seconds}s',
  'auth.verifySuccess': 'Connexion reussie ! Redirection...',
  'auth.verifyError': 'Le lien est invalide ou a expire.',
  'auth.sessionExpired': 'Votre session a expire. Veuillez vous reconnecter.',

  // Home
  'home.title': "Syntheses d'Actualites par IA",
  'home.subtitle': 'Retrouvez ici toutes vos syntheses hebdomadaires generees automatiquement.',
  'home.newSynthesis': 'Nouvelle Synthese',
  'home.empty.title': 'Aucune synthese',
  'home.empty.description': 'Commencez par generer votre premiere synthese hebdomadaire.',
  'home.empty.action': 'Generer',
  'home.weekLabel': 'Semaine {week}',
  'home.readSynthesis': 'Lire la synthese',
  'home.generationInProgress': 'Une generation est en cours...',
  'home.viewProgress': 'Voir la progression',

  // Generate
  'generate.title': 'Generer la Synthese Hebdomadaire',
  'generate.description': "Cette action va lancer l'analyse des actualites des {days} derniers jours sur le theme \"{theme}\" via {provider} ({model}).",
  'generate.note': 'Note : La generation peut prendre jusqu\'a une minute.',
  'generate.noWebSearch': "Note : Le fournisseur selectionne ne dispose pas de la recherche web integree. Les resultats seront bases sur les connaissances du modele uniquement.",
  'generate.start': 'Lancer la generation',
  'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.',
  'generate.success': 'Synthese generee avec succes ! Redirection...',
  'generate.steps.search': "Recherche d'actualites",
  'generate.steps.scraping': 'Verification des sources',
  'generate.steps.rewrite': 'Redaction des resumes',
  'generate.steps.saving': 'Sauvegarde',

  // Synthesis Detail
  'detail.backToList': 'Retour aux syntheses',
  'detail.weekTitle': 'Synthese de la Semaine {week}',
  'detail.generatedOn': 'Generee le {date}',
  'detail.sendEmail': 'Envoyer par email',
  'detail.sending': 'Envoi en cours...',
  'detail.emailSent': "L'email a ete envoye avec succes !",
  'detail.exportPdf': 'Exporter en PDF',
  'detail.exportMarkdown': 'Exporter en Markdown',
  'detail.delete': 'Supprimer',
  'detail.deleteConfirmTitle': 'Supprimer cette synthese ?',
  'detail.deleteConfirmMessage': 'Etes-vous sur de vouloir supprimer cette synthese definitivement ?',
  'detail.deleteConfirm': 'Confirmer la suppression',
  'detail.cancel': 'Annuler',

  // Sources
  'sources.title': 'Sources Personnalisees',
  'sources.subtitle': "Ajoutez des sites web ou des blogs que l'IA devra obligatoirement consulter lors de la generation de vos syntheses.",
  'sources.addTitle': 'Ajouter une source',
  'sources.titlePlaceholder': 'Nom de la source (ex: Blog de Yann LeCun)',
  'sources.urlPlaceholder': 'https://...',
  'sources.add': 'Ajouter',
  'sources.csvSection': 'Import / Export CSV',
  'sources.csvDescription': 'Sauvegardez vos sources ou importez-en de nouvelles depuis un fichier CSV.',
  'sources.exportCsv': 'Exporter en CSV',
  'sources.importCsv': 'Importer depuis un CSV',
  'sources.bulkSection': 'Import en masse',
  'sources.bulkDescription': "Ajoutez plusieurs sources d'un coup. Une source par ligne, au format : Nom de la source;URL",
  'sources.bulkPlaceholder': 'Blog IA;https://blog.ia.com\nNews Tech;https://tech.news.fr',
  'sources.bulkImport': 'Importer les sources',
  'sources.importing': 'Importation...',
  'sources.empty': 'Aucune source personnalisee pour le moment.',
  'sources.deleteConfirmTitle': 'Supprimer cette source ?',
  'sources.deleteConfirmMessage': 'Cette source ne sera plus consultee lors des prochaines generations.',

  // Settings
  'settings.title': 'Parametres de generation',
  'settings.export': 'Exporter',
  'settings.import': 'Importer',
  'settings.theme': 'Theme de la recherche',
  'settings.themeHelp': "Le sujet principal pour la recherche d'actualites.",
  'settings.maxAgeDays': 'Anciennete maximum (jours)',
  'settings.maxItems': 'Actualites max par categorie',
  'settings.searchBehavior': "Comportement de l'agent de recherche",
  'settings.searchBehaviorHelp': "Personnalisez les instructions donnees a l'IA concernant sa methode de recherche.",
  'settings.provider': "Fournisseur d'IA",
  'settings.model': 'Modele',
  'settings.apiKey': 'Cle API',
  'settings.apiKeyPlaceholder': 'Entrez votre cle API pour ce fournisseur',
  'settings.categories': "Categories d'actualite",
  'settings.addCategory': 'Ajouter',
  'settings.newCategory': 'Nouvelle categorie',
  'settings.save': 'Enregistrer les parametres',
  'settings.saved': 'Parametres enregistres avec succes.',
  'settings.saveError': "Erreur lors de l'enregistrement des parametres.",
  'settings.importSuccess': "Configuration importee avec succes. N'oubliez pas d'enregistrer.",
  'settings.importError': "Erreur lors de l'importation du fichier JSON.",
  'settings.providerWarning': "Le fournisseur que vous utilisiez n'est plus disponible. Veuillez en selectionner un autre.",

  // Admin
  'admin.providers.title': 'Configuration des fournisseurs LLM',
  'admin.providers.add': 'Ajouter un fournisseur',
  'admin.providers.name': 'Nom du fournisseur',
  'admin.providers.models': 'Modeles disponibles',
  'admin.providers.defaultModel': 'Modele par defaut',
  'admin.providers.enabled': 'Active',
  'admin.providers.save': 'Enregistrer',
  'admin.rateLimits.title': "Configuration des limites d'usage",
  'admin.rateLimits.globalSection': 'Limites globales',
  'admin.rateLimits.perProvider': 'Par fournisseur',
  'admin.rateLimits.requestsPerMinute': 'Requetes max / minute',
  'admin.rateLimits.save': 'Enregistrer',
  'admin.users.title': 'Gestion des utilisateurs',
  'admin.users.email': 'Email',
  'admin.users.role': 'Role',
  'admin.users.createdAt': "Date d'inscription",

  // Common
  'common.loading': 'Chargement...',
  'common.error': 'Une erreur est survenue.',
  'common.retry': 'Reessayer',
  'common.confirm': 'Confirmer',
  'common.cancel': 'Annuler',
  'common.delete': 'Supprimer',
  'common.save': 'Enregistrer',
} as const;

export type TranslationKey = keyof typeof fr;
export default fr;

5.3 i18n Context and Hook

// src/i18n/index.ts
import { createContext, useContext, ParentComponent, createMemo } from 'solid-js';
import frTranslations, { type TranslationKey } from './locales/fr';

type Translations = Record<TranslationKey, string>;

interface I18nContextType {
  t: (key: TranslationKey, params?: Record<string, string | number>) => string;
  locale: () => string;
}

const translations: Record<string, Translations> = {
  fr: frTranslations,
};

const I18nContext = createContext<I18nContextType>();

export const I18nProvider: ParentComponent<{ locale: string }> = (props) => {
  const locale = () => props.locale;

  const t = (key: TranslationKey, params?: Record<string, string | number>): string => {
    let value = translations[locale()]?.[key] ?? key;
    if (params) {
      Object.entries(params).forEach(([k, v]) => {
        value = value.replace(`{${k}}`, String(v));
      });
    }
    return value;
  };

  return (
    <I18nContext.Provider value={{ t, locale }}>
      {props.children}
    </I18nContext.Provider>
  );
};

export const useI18n = (): I18nContextType => {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error('useI18n must be used within I18nProvider');
  return ctx;
};

5.4 Adding a New Language Later

  1. Create src/i18n/locales/en.ts with the same key structure.
  2. Import it in src/i18n/index.ts and add to the translations map.
  3. Make locale a signal (e.g., from user preferences or browser language).
  4. No changes to any component -- all text comes from t('key').

6. SSE Integration

6.1 EventSource Wrapper -- src/lib/sse.ts

A SolidJS-friendly wrapper around EventSource that maps server events to signal-based callbacks with reconnection logic.

// src/lib/sse.ts

interface SSEOptions {
  maxRetries?: number;       // Default: 3
  retryDelay?: number;       // Default: 1000ms, doubles each retry
  onOpen?: () => void;
  onError?: (error: Event) => void;
}

interface SSEConnection {
  on: (eventType: string, handler: (data: any) => void) => void;
  close: () => void;
  isConnected: () => boolean;
}

export function createSSEConnection(url: string, options: SSEOptions = {}): SSEConnection {
  const maxRetries = options.maxRetries ?? 3;
  const baseDelay = options.retryDelay ?? 1000;

  let eventSource: EventSource | null = null;
  let retryCount = 0;
  let closed = false;
  const handlers = new Map<string, Array<(data: any) => void>>();

  const connect = () => {
    if (closed) return;

    eventSource = new EventSource(url, { withCredentials: true });

    eventSource.onopen = () => {
      retryCount = 0;
      options.onOpen?.();
    };

    eventSource.onerror = (event) => {
      options.onError?.(event);
      eventSource?.close();

      if (!closed && retryCount < maxRetries) {
        const delay = baseDelay * Math.pow(2, retryCount);
        retryCount++;
        setTimeout(connect, delay);
      }
    };

    // Register all known event handlers
    handlers.forEach((handlerList, eventType) => {
      handlerList.forEach((handler) => {
        eventSource!.addEventListener(eventType, (event: MessageEvent) => {
          try {
            const data = JSON.parse(event.data);
            handler(data);
          } catch {
            handler(event.data);
          }
        });
      });
    });
  };

  const on = (eventType: string, handler: (data: any) => void) => {
    if (!handlers.has(eventType)) {
      handlers.set(eventType, []);
    }
    handlers.get(eventType)!.push(handler);

    // If already connected, add listener to existing eventSource
    if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
      eventSource.addEventListener(eventType, (event: MessageEvent) => {
        try {
          const data = JSON.parse(event.data);
          handler(data);
        } catch {
          handler(event.data);
        }
      });
    }
  };

  const close = () => {
    closed = true;
    eventSource?.close();
    eventSource = null;
  };

  const isConnected = () =>
    eventSource !== null && eventSource.readyState === EventSource.OPEN;

  // Start connection
  connect();

  return { on, close, isConnected };
}

6.2 Generation Progress Display

Used in GenerateSynthesis.tsx:

// In GenerateSynthesis.tsx
createEffect(() => {
  const id = jobId();
  if (!id) return;

  const sse = createSSEConnection(
    `/api/v1/syntheses/generate/${id}/progress`
  );

  sse.on('progress', (data: SSEProgressEvent) => {
    setProgress(data);
  });

  sse.on('complete', (data: { synthesis_id: string }) => {
    setStatus('complete');
    setTimeout(() => navigate(`/synthesis/${data.synthesis_id}`), 1500);
  });

  sse.on('error', (data: { message: string }) => {
    setStatus('error');
    setErrorMessage(data.message);
  });

  onCleanup(() => sse.close());
});

6.3 Real-Time List Updates

For the Home page (synthesis list) and Sources page, SSE provides real-time updates when data changes server-side.

Pattern: After initial createResource fetch, open an SSE connection to an events endpoint. Use mutate() from createResource to update the local data.

// Home page SSE for list updates
createEffect(() => {
  const sse = createSSEConnection('/api/v1/syntheses/events');

  sse.on('created', (synthesis: Synthesis) => {
    mutate((prev) => prev ? [synthesis, ...prev] : [synthesis]);
  });

  sse.on('deleted', (data: { id: string }) => {
    mutate((prev) => prev?.filter((s) => s.id !== data.id) ?? []);
  });

  sse.on('updated', (synthesis: Synthesis) => {
    mutate((prev) =>
      prev?.map((s) => (s.id === synthesis.id ? synthesis : s)) ?? []
    );
  });

  onCleanup(() => sse.close());
});

6.4 Reconnection Strategy

  • Exponential backoff: Start at 1s, double each retry, max 3 retries.
  • On reconnect: The client re-opens the SSE connection. The backend should replay any missed events since the last event ID (using Last-Event-Id header).
  • On permanent failure (after max retries): Fall back to polling via setInterval + refetch() every 10 seconds.
  • On 401: Do not retry. Redirect to login (session expired).

7. Tailwind CSS

7.1 Replicating the Current Visual Design

The current app uses a consistent indigo-based design system via Tailwind utility classes. Since Tailwind is framework-agnostic, the same utility classes transfer directly to SolidJS JSX with one systematic change: className becomes class.

Color scheme to preserve:

  • Primary: indigo-600 (buttons, links, badges, focus rings), indigo-700 (hover)
  • Background: gray-50 (page), white (cards)
  • Text: gray-900 (headings), gray-700 (body), gray-500 (secondary), gray-400 (icons)
  • Borders: gray-200 (cards), gray-300 (inputs), indigo-300 (hover accent)
  • Success: green-50/green-400/green-600/green-800
  • Error: red-50/red-400/red-600/red-800

Card patterns:

<!-- Standard card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<!-- Hoverable card (synthesis list) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200">

Button patterns:

<!-- Primary -->
<button class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<!-- Secondary -->
<button class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<!-- Danger -->
<button class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">

Form input patterns:

<input class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border">

7.2 Tailwind v4 Configuration

Tailwind v4 uses CSS-based configuration. The index.css file:

/* src/index.css */
@import "tailwindcss";

/* Custom theme overrides (if needed beyond defaults) */
@theme {
  --color-primary: var(--color-indigo-600);
  --color-primary-hover: var(--color-indigo-700);
}

Tailwind v4 auto-detects content sources from the Vite plugin, so no explicit content configuration is needed.

7.3 Responsive Design Patterns

The current app uses these breakpoint patterns (to preserve):

Pattern Usage
sm:grid-cols-2 lg:grid-cols-3 Synthesis card grid on Home
sm:flex sm:space-y-0 sm:space-x-4 Source add form (stack on mobile, row on desktop)
sm:px-6 lg:px-8 Content area padding
max-w-5xl Home page width
max-w-4xl Sources, SynthesisDetail
max-w-3xl Settings, GenerateSynthesis
max-w-md Login/Register

New: Mobile navigation fix.

The current app hides nav links on mobile with hidden sm:flex. The new version adds a hamburger menu:

<!-- Mobile hamburger button (visible only on small screens) -->
<button class="sm:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100">
  <Menu class="h-6 w-6" />
</button>

<!-- Desktop nav links (hidden on mobile) -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
  ...
</div>

8. Forms and Validation

8.1 Form Handling Pattern

SolidJS forms use createStore for complex form state (like Settings with nested categories) and createSignal for simple forms (like login email field).

Simple form (Login):

const [email, setEmail] = createSignal('');
const [error, setError] = createSignal<string | null>(null);

const handleSubmit = async (e: SubmitEvent) => {
  e.preventDefault();
  const validationError = validateEmail(email());
  if (validationError) {
    setError(validationError);
    return;
  }
  // ... submit
};

Complex form (Settings):

import { createStore, produce } from 'solid-js/store';

const [form, setForm] = createStore<UserSettings>({ ...DEFAULT_SETTINGS });
const [errors, setErrors] = createStore<Record<string, string>>({});

// Path-based update
setForm('theme', 'Cybersecurite');
setForm('categories', 2, 'New category name');

// Array mutations via produce
setForm(produce((draft) => {
  draft.categories.splice(index, 1);
}));

8.2 Validation Approach

Client-side validation runs on submit (not on every keystroke, to avoid aggressive UX). Field-level errors display below each field.

// src/lib/validation.ts

export interface ValidationRule {
  test: (value: any) => boolean;
  message: string;
}

export const required = (fieldName: string): ValidationRule => ({
  test: (v) => v !== null && v !== undefined && String(v).trim() !== '',
  message: `${fieldName} est requis.`,
});

export const email = (): ValidationRule => ({
  test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)),
  message: 'Adresse email invalide.',
});

export const url = (): ValidationRule => ({
  test: (v) => {
    try {
      new URL(String(v));
      return true;
    } catch {
      return false;
    }
  },
  message: 'URL invalide.',
});

export const minLength = (min: number): ValidationRule => ({
  test: (v) => String(v).length >= min,
  message: `Minimum ${min} caracteres.`,
});

export const maxLength = (max: number): ValidationRule => ({
  test: (v) => String(v).length <= max,
  message: `Maximum ${max} caracteres.`,
});

export const numberRange = (min: number, max: number): ValidationRule => ({
  test: (v) => Number(v) >= min && Number(v) <= max,
  message: `Doit etre entre ${min} et ${max}.`,
});

export function validate(
  value: any,
  rules: ValidationRule[]
): string | null {
  for (const rule of rules) {
    if (!rule.test(value)) return rule.message;
  }
  return null;
}

export function validateForm(
  fields: Record<string, { value: any; rules: ValidationRule[] }>
): Record<string, string> {
  const errors: Record<string, string> = {};
  for (const [key, { value, rules }] of Object.entries(fields)) {
    const error = validate(value, rules);
    if (error) errors[key] = error;
  }
  return errors;
}

8.3 Error Display Pattern

The FormField component wraps inputs with label and error display:

// src/components/forms/FormField.tsx
interface FormFieldProps {
  label: string;
  error?: string;
  helpText?: string;
  required?: boolean;
  children: JSX.Element;
}

const FormField: Component<FormFieldProps> = (props) => {
  return (
    <div>
      <label class="block text-sm font-medium text-gray-700">
        {props.label}
        <Show when={props.required}>
          <span class="text-red-500 ml-1">*</span>
        </Show>
      </label>
      <div class="mt-1">
        {props.children}
      </div>
      <Show when={props.error}>
        <p class="mt-1 text-sm text-red-600">{props.error}</p>
      </Show>
      <Show when={props.helpText && !props.error}>
        <p class="mt-1 text-sm text-gray-500">{props.helpText}</p>
      </Show>
    </div>
  );
};

9. PDF/Markdown Export

9.1 Client-Side PDF Generation

Using jspdf + jspdf-autotable for PDF generation. No server round-trip needed.

// src/lib/export.ts
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import type { Synthesis, NewsSection } from '~/types/synthesis';

export function generatePdf(synthesis: Synthesis): void {
  const doc = new jsPDF();
  const weekNum = synthesis.week.split('-W')[1];

  // Title
  doc.setFontSize(20);
  doc.text(`Synthese de la Semaine ${weekNum}`, 14, 22);

  // Date
  doc.setFontSize(10);
  doc.setTextColor(100);
  doc.text(
    `Generee le ${format(new Date(synthesis.created_at), 'dd MMMM yyyy', { locale: fr })}`,
    14,
    30
  );

  let yOffset = 40;

  for (const section of synthesis.sections) {
    // Section title
    doc.setFontSize(14);
    doc.setTextColor(0);
    doc.text(section.title, 14, yOffset);
    yOffset += 8;

    // Table of items
    autoTable(doc, {
      startY: yOffset,
      head: [['Titre', 'Resume']],
      body: section.items.map((item) => [item.title, item.summary]),
      styles: { fontSize: 9, cellPadding: 4 },
      headStyles: { fillColor: [79, 70, 229] }, // indigo-600
      columnStyles: {
        0: { cellWidth: 60 },
        1: { cellWidth: 'auto' },
      },
      didDrawPage: (data) => {
        yOffset = data.cursor?.y ?? yOffset;
      },
    });

    yOffset = (doc as any).lastAutoTable.finalY + 12;

    // Add new page if needed
    if (yOffset > 270) {
      doc.addPage();
      yOffset = 20;
    }
  }

  doc.save(`synthese-semaine-${weekNum}.pdf`);
}

9.2 Markdown Generation

export function generateMarkdown(synthesis: Synthesis): void {
  const weekNum = synthesis.week.split('-W')[1];
  const date = format(new Date(synthesis.created_at), 'dd MMMM yyyy', { locale: fr });

  let md = `# Synthese de la Semaine ${weekNum}\n\n`;
  md += `*Generee le ${date}*\n\n`;

  for (const section of synthesis.sections) {
    md += `## ${section.title}\n\n`;
    for (const item of section.items) {
      md += `### [${item.title}](${item.url})\n\n`;
      md += `${item.summary}\n\n`;
    }
  }

  // Trigger download
  const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `synthese-semaine-${weekNum}.md`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}

9.3 Integration in SynthesisDetail

// In SynthesisDetail.tsx action bar
<div class="flex gap-3">
  <Button variant="secondary" icon={FileDown} onClick={() => generatePdf(synthesis()!)}>
    {t('detail.exportPdf')}
  </Button>
  <Button variant="secondary" icon={FileText} onClick={() => generateMarkdown(synthesis()!)}>
    {t('detail.exportMarkdown')}
  </Button>
</div>

10. React-to-SolidJS Migration Map

10.1 File Mapping Table

React File SolidJS File Key Differences Gotchas
src/App.tsx (Login) src/pages/auth/Login.tsx Google SSO button replaced by email+Turnstile form. Magic link flow. Turnstile requires imperative script loading in onMount
src/App.tsx (ProtectedRoute) src/components/auth/ProtectedRoute.tsx if/return pattern replaced by nested <Show>. useAuth() returns accessor functions, not values. Must call user() not just user to read the signal
src/App.tsx (Layout) src/components/layout/AppShell.tsx + Navbar.tsx + MobileMenu.tsx Split into 3 components. Added mobile menu. Added active route indicator. className -> class. props.children not {children} destructure
src/App.tsx (Router) src/App.tsx BrowserRouter/Routes/Route -> Router/Route from @solidjs/router. Nested routes replace per-route <ProtectedRoute> wrapping. Route nesting is structural (layout-based), not wrapping
src/components/AuthContext.tsx src/context/AuthContext.tsx useState/useEffect/onAuthStateChanged -> createSignal/onMount/GET /api/v1/auth/me. Firebase auth removed entirely. Signal accessors: user() is a function call
src/pages/Home.tsx src/pages/Home.tsx useState+useEffect+onSnapshot -> createResource+SSE. {array.map()} -> <For each={}>. className -> class. Firestore Timestamp -> ISO date string. <For> callback receives item as argument, not destructured. format(synth.createdAt.toDate(), ...) becomes format(new Date(synth.created_at), ...)
src/pages/GenerateSynthesis.tsx src/pages/GenerateSynthesis.tsx Client-side Gemini call replaced by POST /api/v1/syntheses/generate + SSE progress. Single spinner replaced by multi-step progress indicator. SSE connection must be cleaned up with onCleanup. Job continues server-side if user navigates away.
src/pages/SynthesisDetail.tsx src/pages/SynthesisDetail.tsx onSnapshot -> createResource. Gmail OAuth email -> POST /api/v1/syntheses/:id/email. Added PDF/Markdown export buttons. Legacy data rendering removed. Hardcoded email removed. useParams() returns a reactive proxy -- do not destructure. Use params.id directly.
src/pages/Sources.tsx src/pages/Sources.tsx onSnapshot -> createResource + SSE. React.FormEvent -> SubmitEvent. React.ChangeEvent<HTMLInputElement> -> Event. CSV import as multipart to backend endpoint. Added delete confirmation. File input onChange types differ -- use (e.target as HTMLInputElement).files
src/pages/Settings.tsx src/pages/Settings.tsx useState<AppSettings> -> createStore<UserSettings>. Hardcoded model options -> dynamic from GET /api/v1/config/providers. Added provider dropdown and API key field. createStore uses path-based setters, not spread-based. Category array updates need produce() or index-based setters.
src/types.ts src/types/ (split into multiple files) Firestore Timestamp removed. SynthesisData legacy fields removed. New types for auth, admin, providers. camelCase -> snake_case for API fields. Ensure frontend types match backend JSON field names (snake_case)
src/index.css src/index.css Identical: @import "tailwindcss" No change needed
src/firebase.ts Removed entirely Replaced by src/api/client.ts + domain-specific API modules --
src/services/geminiService.ts Removed entirely All LLM logic moves to Rust backend --

10.2 Pattern Mapping Reference

React Pattern SolidJS Equivalent Notes
useState(initialValue) createSignal(initialValue) Returns [getter, setter] where getter is a function
useState({...complex}) createStore({...complex}) For nested objects. Use path-based setters.
useEffect(() => {}, [deps]) createEffect(() => {}) Auto-tracks dependencies. No dep array needed.
useEffect(() => { return cleanup }, []) onMount(() => {}); onCleanup(() => {}) Separate mount and cleanup
useRef() let ref: HTMLElement | undefined Direct variable assignment. Pass via ref={el => ref = el}
useMemo(() => computed, [deps]) createMemo(() => computed) Auto-tracks. Returns accessor function.
useCallback(() => fn, [deps]) Just use the function directly SolidJS does not re-render, so memoizing callbacks is unnecessary
React.FC<Props> Component<Props> (from solid-js) --
{condition && <Component />} <Show when={condition}><Component /></Show> <Show> avoids evaluating the false branch
{condition ? <A /> : <B />} <Show when={condition} fallback={<B />}><A /></Show> --
{items.map(item => <C key={item.id} />)} <For each={items}>{(item) => <C />}</For> <For> is keyed by reference. Add key via index param if needed.
<Link to="/"> <A href="/"> From @solidjs/router
useParams<{id: string}>() useParams() Returns reactive proxy. Access params.id, do not destructure.
useNavigate() useNavigate() Same API: navigate('/path')
className="..." class="..." Systematic find-and-replace. class is standard HTML.
onChange={(e) => setValue(e.target.value)} onInput={(e) => setValue(e.currentTarget.value)} onInput fires on every keystroke. onChange fires on blur in SolidJS (matching DOM behavior). Use onInput for real-time input binding.
e: React.ChangeEvent<HTMLInputElement> e: InputEvent & { currentTarget: HTMLInputElement } Native DOM event types
e: React.FormEvent e: SubmitEvent Native DOM event type
dangerouslySetInnerHTML innerHTML (JSX attribute) Both should be avoided when possible

11. Testing

11.1 Setup

Vitest is configured automatically via the Vite config. Additional setup for SolidJS:

// vitest.config.ts (or in vite.config.ts)
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';

export default defineConfig({
  plugins: [solid()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    transformMode: {
      web: [/\.[jt]sx?$/],
    },
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

11.2 Component Test Examples

Testing the Spinner component:

// src/components/ui/Spinner.test.tsx
import { render, screen } from '@solidjs/testing-library';
import Spinner from './Spinner';

describe('Spinner', () => {
  it('renders a spinner with default size', () => {
    render(() => <Spinner />);
    const spinner = screen.getByRole('status');
    expect(spinner).toBeInTheDocument();
  });

  it('renders full-page spinner when fullPage is true', () => {
    render(() => <Spinner fullPage />);
    const container = screen.getByRole('status').parentElement;
    expect(container).toHaveClass('h-screen');
  });
});

Testing the ProtectedRoute:

// src/components/auth/ProtectedRoute.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { Router } from '@solidjs/router';
import { AuthProvider } from '~/context/AuthContext';
import ProtectedRoute from './ProtectedRoute';

// Mock the auth API
vi.mock('~/api/auth', () => ({
  authApi: {
    me: vi.fn().mockRejectedValue(new Error('Unauthorized')),
  },
}));

describe('ProtectedRoute', () => {
  it('shows spinner while loading', () => {
    render(() => (
      <Router>
        <AuthProvider>
          <ProtectedRoute>
            <div>Protected content</div>
          </ProtectedRoute>
        </AuthProvider>
      </Router>
    ));
    expect(screen.getByRole('status')).toBeInTheDocument();
  });
});

Testing the API client:

// src/api/client.test.ts
import { api } from './client';

describe('ApiClient', () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it('sends X-Requested-With header for CSRF protection', async () => {
    const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ data: 'test' }), { status: 200 })
    );

    await api.get('/test');

    expect(fetchSpy).toHaveBeenCalledWith(
      '/api/v1/test',
      expect.objectContaining({
        headers: expect.objectContaining({
          'X-Requested-With': 'XMLHttpRequest',
        }),
      })
    );
  });

  it('redirects to /login on 401 response', async () => {
    const originalLocation = window.location;
    delete (window as any).location;
    window.location = { href: '' } as Location;

    vi.spyOn(global, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
    );

    await expect(api.get('/protected')).rejects.toThrow('Session expired');
    expect(window.location.href).toBe('/login');

    window.location = originalLocation;
  });
});

11.3 What to Test

Area What to test Priority
API client CSRF header sent, 401 redirect, error parsing, JSON serialization High
Auth flow Session check on mount, login triggers API call, logout clears state, redirect on unauthenticated High
ProtectedRoute Shows spinner while loading, redirects when unauthenticated, renders children when authenticated High
SSE wrapper Connection opens, events parsed, reconnection on error, cleanup on close High
Form validation Email validation, URL validation, required fields, number ranges Medium
SynthesisCard Renders week number, date, preview items, delete button Medium
Settings form Category add/remove, provider/model cascading, save triggers API call Medium
PDF export Generates valid PDF blob (smoke test) Low
i18n Translation key lookup, parameter interpolation, missing key fallback Low
Toast system Toast appears, auto-dismisses, multiple toasts stack Low

11.4 Integration Test Pattern

For pages that make API calls, mock the API module and test the full page lifecycle:

// src/pages/Home.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import { Router } from '@solidjs/router';
import Home from './Home';

vi.mock('~/api/syntheses', () => ({
  synthesesApi: {
    list: vi.fn().mockResolvedValue([
      {
        id: '1',
        week: '2026-W12',
        created_at: '2026-03-21T10:00:00Z',
        sections: [
          {
            title: 'Annonces majeures',
            items: [{ title: 'Test article', url: 'https://example.com', summary: 'A summary' }],
          },
        ],
      },
    ]),
  },
}));

describe('Home', () => {
  it('renders synthesis cards after loading', async () => {
    render(() => (
      <Router>
        <Home />
      </Router>
    ));

    // Initially shows spinner
    expect(screen.getByRole('status')).toBeInTheDocument();

    // After loading, shows synthesis card
    await waitFor(() => {
      expect(screen.getByText('Semaine 12')).toBeInTheDocument();
      expect(screen.getByText('Test article')).toBeInTheDocument();
    });
  });
});

Type Definitions Summary

src/types/auth.ts

export interface User {
  id: string;
  email: string;
  display_name: string | null;
  role: 'user' | 'admin';
  created_at: string; // ISO 8601
}

export interface RegisterRequest {
  email: string;
  display_name?: string;
  captcha_token: string;
}

export interface LoginRequest {
  email: string;
  captcha_token: string;
}

export interface AuthMessageResponse {
  message: string;
}

src/types/synthesis.ts

export interface NewsItem {
  title: string;
  url: string;
  summary: string;
}

export interface NewsSection {
  title: string;
  items: NewsItem[];
}

export interface Synthesis {
  id: string;
  week: string; // e.g., "2026-W12"
  created_at: string; // ISO 8601
  sections: NewsSection[];
}

export interface GenerateResponse {
  job_id: string;
  status: 'pending';
}

export interface SSEProgressEvent {
  step: 'search' | 'scraping' | 'rewrite' | 'saving';
  message: string;
  percent: number;
}

export interface SSECompleteEvent {
  synthesis_id: string;
}

export interface SSEErrorEvent {
  message: string;
}

export type GenerationStatus = 'idle' | 'generating' | 'complete' | 'error';

src/types/source.ts

export interface Source {
  id: string;
  title: string;
  url: string;
  created_at: string; // ISO 8601
}

export interface CreateSourceRequest {
  title: string;
  url: string;
}

export interface BulkImportRequest {
  sources: CreateSourceRequest[];
}

src/types/settings.ts

export interface UserSettings {
  theme: string;
  max_age_days: number;
  categories: string[];
  max_items_per_category: number;
  search_agent_behavior: string;
  provider_id: string;
  model_name: string;
  api_key: string; // User's own API key (sent encrypted by backend)
}

export const DEFAULT_SETTINGS: UserSettings = {
  theme: 'Intelligence Artificielle',
  max_age_days: 7,
  categories: [
    'Annonces majeures / importantes',
    'Entreprises des secteurs financiers (banques, assurances, etc.)',
    'Grandes entreprises des autres secteurs',
    'Secteurs publics (Defense, Education, etc.)',
    'Grand public / Particuliers',
  ],
  max_items_per_category: 4,
  search_agent_behavior:
    "Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google.",
  provider_id: '',
  model_name: '',
  api_key: '',
};

src/types/admin.ts

export interface ProviderConfig {
  id: string;
  name: string; // e.g., "Google Gemini", "OpenAI", "Anthropic"
  slug: string; // e.g., "gemini", "openai", "anthropic"
  models: ModelConfig[];
  enabled: boolean;
}

export interface ModelConfig {
  name: string; // e.g., "gemini-3.1-pro-preview"
  display_name: string; // e.g., "Gemini 3.1 Pro (conseille)"
  supports_web_search: boolean;
}

export interface RateLimitConfig {
  provider_id: string;
  provider_name: string;
  requests_per_minute: number;
}

export interface AdminUser {
  id: string;
  email: string;
  display_name: string | null;
  role: 'user' | 'admin';
  created_at: string;
}

src/types/api.ts

export interface ApiError {
  status: number;
  message: string;
  field_errors?: Record<string, string>;
}

export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  per_page: number;
}

Appendix: Cloudflare Turnstile TypeScript Declarations

Since Turnstile is loaded via script tag and used imperatively, add type declarations:

// src/types/turnstile.d.ts
declare global {
  interface Window {
    turnstile: {
      render: (
        container: HTMLElement,
        options: {
          sitekey: string;
          callback: (token: string) => void;
          'expired-callback'?: () => void;
          'error-callback'?: () => void;
          theme?: 'light' | 'dark' | 'auto';
          size?: 'normal' | 'compact';
        }
      ) => string; // Returns widget ID
      reset: (widgetId: string) => void;
      remove: (widgetId: string) => void;
    };
  }
}

export {};

Appendix: Environment Variables

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_TURNSTILE_SITE_KEY: string;
  readonly VITE_API_BASE_URL?: string; // Optional override for non-proxy setups
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}