diff --git a/.gitignore b/.gitignore
index afea2f4..5dd107d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ playwright-report/
# Browser-tooling session artifacts (screenshots/snapshots from MCP-driven runs)
.playwright-mcp/
+dist-ssr
diff --git a/backend/API.md b/backend/API.md
index d4344c2..aa81a06 100644
--- a/backend/API.md
+++ b/backend/API.md
@@ -11,6 +11,13 @@ human-readable summary; the spec is the machine-readable source of truth.
## Conventions
+### Versioning
+
+Every `/api/*` endpoint below is also mounted, identically, under `/api/v1/*`.
+The unversioned prefix is the canonical one used by the frontend; external
+integrations should pin `/api/v1` so a future breaking change (shipped as
+`/api/v2`) never affects them.
+
### Authentication
The session is carried by **HttpOnly cookies** set on login (`frameset_access_token`,
diff --git a/backend/migrations/024_harmonize_collations.sql b/backend/migrations/024_harmonize_collations.sql
new file mode 100644
index 0000000..04736e8
--- /dev/null
+++ b/backend/migrations/024_harmonize_collations.sql
@@ -0,0 +1,57 @@
+-- Harmonizes text collations on utf8mb4_unicode_ci across the schema.
+--
+-- 001_init created `users` (and later 023 `user_recovery_codes`) as
+-- utf8mb4_unicode_ci but the project tables as utf8mb4_general_ci — two
+-- different accent/case comparison rules living side by side. Nothing
+-- compares text across those tables today, which is exactly why now is the
+-- cheap moment to converge: one collation, one sorting behavior, no surprise
+-- the day a cross-table comparison or UNION appears.
+--
+-- Deliberately NOT touched: `revoked_tokens`, whose `token` column is
+-- intentionally ascii_bin (lowercase hex digests — see 021); CONVERT TO
+-- CHARACTER SET rewrites every text column of a table and would clobber it.
+--
+-- Same idempotency style as 021: guarded via information_schema, so re-runs
+-- (and already-converted databases) are no-ops. CONVERT TO also rebuilds the
+-- indexes on the converted columns; these tables are small (per-user content,
+-- 50-color palette cap), so the rewrite is instantaneous at this scale.
+
+SET @collation := (
+ SELECT TABLE_COLLATION FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'projects'
+);
+SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
+ 'ALTER TABLE `projects` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @collation := (
+ SELECT TABLE_COLLATION FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_brush_norms'
+);
+SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
+ 'ALTER TABLE `project_brush_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @collation := (
+ SELECT TABLE_COLLATION FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_typography_norms'
+);
+SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
+ 'ALTER TABLE `project_typography_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @collation := (
+ SELECT TABLE_COLLATION FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_palette'
+);
+SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
+ 'ALTER TABLE `project_palette` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/backend/package-lock.json b/backend/package-lock.json
index c00c20e..e8d415c 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -4750,9 +4750,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
diff --git a/backend/src/app.js b/backend/src/app.js
index 7edff42..ee79d7a 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -243,6 +243,18 @@ app.use('/api/fonts', fontsRoutes);
// exemption applies; rate limited inside the router).
app.use('/api/share', shareRoutes);
+// Versioned alias: the exact same routers, additionally reachable under
+// /api/v1/* so an external integration can pin a version from day one. The
+// unversioned /api remains the canonical path used by the SPA; a future
+// breaking change would fork new routers under /api/v2 while these keep
+// serving v1 clients unchanged. The CSRF middlewares above are mounted on
+// '/api' and therefore already cover this prefix.
+app.use('/api/v1/auth', authRoutes);
+app.use('/api/v1/users', userRoutes);
+app.use('/api/v1/projects', projectsRoutes);
+app.use('/api/v1/fonts', fontsRoutes);
+app.use('/api/v1/share', shareRoutes);
+
// E2E test mode only: lets a Playwright run read a verification code without
// a real inbox. Never mounted otherwise — see utils/testMode.js.
if (isE2ETestMode) {
diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js
index ef326b0..5e8a644 100644
--- a/backend/src/docs/openapi.js
+++ b/backend/src/docs/openapi.js
@@ -26,7 +26,10 @@ const openapiSpec = {
'(`POST`/`PUT`/`PATCH`/`DELETE`) use the double-submit CSRF pattern: send the ' +
'`frameset_csrf_token` cookie value back in the `x-csrf-token` header ' +
'(fetch one via `GET /api/auth/csrf-token`). The JSON body is capped at 10 kB ' +
- 'and sensitive endpoints are rate limited.',
+ 'and sensitive endpoints are rate limited.\n\n' +
+ '**Versioning**: every `/api/*` path documented here is also mounted, identically, ' +
+ 'under `/api/v1/*` — pin the versioned prefix for external integrations; a breaking ' +
+ 'change would ship as `/api/v2` while `/api/v1` keeps serving existing clients.',
contact: { name: 'Axelle Tempier', email: 'axelle.tempier@gmail.com' },
},
servers: [{ url: '/', description: 'Same origin as the app' }],
diff --git a/backend/src/docs/paths/projects.js b/backend/src/docs/paths/projects.js
index fe30553..9c88cf8 100644
--- a/backend/src/docs/paths/projects.js
+++ b/backend/src/docs/paths/projects.js
@@ -436,9 +436,18 @@ module.exports = {
properties: {
name: { type: 'string' },
ownerName: { type: 'string' },
- brushNorms: { type: 'array', items: { type: 'object' } },
- typographyNorms: { type: 'array', items: { type: 'object' } },
- palette: { type: 'array', items: { type: 'object' } },
+ brushNorms: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/BrushNorm' },
+ },
+ typographyNorms: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/TypographyNorm' },
+ },
+ palette: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/PaletteColor' },
+ },
},
},
},
diff --git a/backend/tests/unit/app.test.js b/backend/tests/unit/app.test.js
index 274f754..67fd728 100644
--- a/backend/tests/unit/app.test.js
+++ b/backend/tests/unit/app.test.js
@@ -46,6 +46,23 @@ describe('application middleware', () => {
});
});
+ describe('versioned API alias', () => {
+ it('serves the same surface under /api/v1, CSRF protection included', async () => {
+ // Same endpoint, both prefixes.
+ const unversioned = await request(app).get('/api/auth/csrf-token');
+ const versioned = await request(app).get('/api/v1/auth/csrf-token');
+ expect(unversioned.status).toBe(200);
+ expect(versioned.status).toBe(200);
+ expect(versioned.body).toEqual(expect.objectContaining({ csrfToken: expect.any(String) }));
+
+ // The '/api'-mounted CSRF guard covers the alias too: an unprotected
+ // mutation is rejected exactly like on the canonical prefix.
+ const rejected = await request(app).post('/api/v1/auth/logout');
+ expect(rejected.status).toBe(403);
+ expect(rejected.body.error).toMatch(/csrf/i);
+ });
+ });
+
describe('security headers', () => {
it('exposes an explicit Content-Security-Policy header', async () => {
const res = await request(app).get('/api/auth/csrf-token');
diff --git a/backend/tests/unit/openapiSync.test.js b/backend/tests/unit/openapiSync.test.js
index 9d21b4c..9f96394 100644
--- a/backend/tests/unit/openapiSync.test.js
+++ b/backend/tests/unit/openapiSync.test.js
@@ -54,11 +54,17 @@ describe('OpenAPI spec stays in sync with the mounted routes', () => {
.map(normalizeRoutePath)
// The comparison covers the documented surface: /health and /api/*.
// Swagger's own endpoints (/api-docs*) and the E2E-only /api/_test
- // routes (not mounted here) are intentionally undocumented.
+ // routes (not mounted here) are intentionally undocumented. /api/v1/*
+ // is a mount-level ALIAS of the same routers (see app.js): its surface
+ // is identical by construction, so it is documented once via the spec's
+ // servers list rather than duplicating every path entry.
.filter(
(route) =>
/ \/health$/.test(route) ||
- (/ \/api\//.test(route) && !/ \/api-docs/.test(route) && !/ \/api\/_test/.test(route)),
+ (/ \/api\//.test(route) &&
+ !/ \/api-docs/.test(route) &&
+ !/ \/api\/_test/.test(route) &&
+ !/ \/api\/v1\//.test(route)),
);
const documented = Object.entries(openapiSpec.paths).flatMap(([specPath, operations]) =>
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
index 6715024..876c211 100644
--- a/frontend/.prettierignore
+++ b/frontend/.prettierignore
@@ -5,3 +5,4 @@ package-lock.json
public
test-results
playwright-report
+dist-ssr
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index e3c42d0..2855e60 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -20,7 +20,7 @@ const vitestGlobals = {
export default [
// Build output, dependencies and coverage reports are never linted.
- { ignores: ['dist', 'node_modules', 'coverage'] },
+ { ignores: ['dist', 'dist-ssr', 'node_modules', 'coverage'] },
// Application and test source: browser environment, React component rules.
{
diff --git a/frontend/index.html b/frontend/index.html
index e1805dc..74b054c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -29,7 +29,7 @@
content="FrameSet keeps every project's graphic standards and color palette in one place."
/>
-
+
@@ -38,7 +38,7 @@
name="twitter:description"
content="FrameSet keeps every project's graphic standards and color palette in one place."
/>
-
+
diff --git a/frontend/package.json b/frontend/package.json
index 4111ec7..4085a58 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/frontend/public/og-cover.png b/frontend/public/og-cover.png
new file mode 100644
index 0000000..95fcabd
Binary files /dev/null and b/frontend/public/og-cover.png differ
diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs
new file mode 100644
index 0000000..4eeb5f7
--- /dev/null
+++ b/frontend/scripts/prerender.mjs
@@ -0,0 +1,68 @@
+/**
+ * Build-time prerendering of the public, auth-free routes: after the client
+ * build, the SSR bundle (src/entry-server.jsx, built to dist-ssr/) renders
+ * each route to HTML which is injected into dist/index.html's empty #root and
+ * written as /index.html. Vercel serves matching static files before
+ * the SPA rewrite kicks in, so first paint no longer waits for React to boot
+ * — the app then hydrates in place (see src/main.jsx).
+ *
+ * Only routes listed here may be prerendered, and their page components MUST
+ * be imported eagerly in App.jsx (renderToString cannot await a lazy chunk).
+ * Auth-gated routes (login/register) are deliberately absent: their guard
+ * renders a loading state until the session probe settles, so their prerender
+ * would be an empty spinner.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const ROUTES = ['/', '/privacy', '/terms'];
+const ROOT_MARKER = '';
+
+const { render } = await import(pathToFileURL(path.join(root, 'dist-ssr', 'entry-server.js')).href);
+const template = fs.readFileSync(path.join(root, 'dist', 'index.html'), 'utf8');
+if (!template.includes(ROOT_MARKER)) {
+ throw new Error('prerender: dist/index.html no longer contains an empty #root to inject into.');
+}
+
+// The EMPTY shell must keep existing for every non-prerendered route: the SPA
+// fallback rewrite points at /app-shell.html (see vercel.json), because
+// index.html itself becomes the prerendered landing below. Serving the
+// landing's HTML to, say, /login would make hydration mismatch on purpose.
+fs.writeFileSync(path.join(root, 'dist', 'app-shell.html'), template);
+
+// Prerendered pages also get the WHOLE stylesheet inlined in place of the
+// render-blocking : first paint then needs nothing beyond the HTML
+// itself. Inlining everything (not "critical CSS" extraction) is deliberate —
+// every rule is present, so dark mode and every breakpoint render exactly
+// right with zero flash; at ~40KB (a few KB gzipped) the weight is cheap.
+// The shell keeps the plain , so the rest of the app is untouched.
+const linkMatch = template.match(/]*href="([^"]+\.css)"[^>]*>/);
+if (!linkMatch) {
+ throw new Error('prerender: could not find the stylesheet in dist/index.html.');
+}
+const css = fs.readFileSync(path.join(root, 'dist', linkMatch[1].replace(/^\//, '')), 'utf8');
+const inlineCss = (page) => page.replace(linkMatch[0], ``);
+
+for (const route of ROUTES) {
+ const html = render(route);
+ if (!html || html.length < 500) {
+ throw new Error(`prerender: suspiciously small render for ${route} (${html.length} chars).`);
+ }
+ // The route is stamped on the container so main.jsx only hydrates when the
+ // prerendered HTML actually belongs to the URL being served (a misrouted
+ // fallback falls back to a clean client render instead of a mismatch).
+ const page = inlineCss(
+ template.replace(ROOT_MARKER, `
${html}
`),
+ );
+ const outFile =
+ route === '/'
+ ? path.join(root, 'dist', 'index.html')
+ : path.join(root, 'dist', route.slice(1), 'index.html');
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
+ fs.writeFileSync(outFile, page);
+ console.log(
+ `prerendered ${route} -> ${path.relative(root, outFile)} (${Math.round(page.length / 1024)}KB)`,
+ );
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index cc949ce..3a8250a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -15,9 +15,17 @@ import CursorDot from './components/CursorDot';
import { captureException } from './utils/monitoring';
import MainLayout from './layouts/MainLayout';
-// Pages are code-split via React.lazy so each route's JS (and heavy deps like
-// jsPDF / react-select) is only downloaded when that route is first visited.
-const Landing = lazy(() => import('./pages/Landing'));
+// Prerendered routes (see scripts/prerender.mjs) are imported EAGERLY: the
+// server-side renderToString can't await a lazy chunk, and an eager import
+// also guarantees hydration never suspends on them. They are light,
+// markup-heavy pages, so the main bundle barely grows.
+import Landing from './pages/Landing';
+import Terms from './pages/Terms';
+import Privacy from './pages/Privacy';
+
+// Every other page stays code-split via React.lazy so each route's JS (and
+// heavy deps like jsPDF / react-select) is only downloaded when that route is
+// first visited.
const Login = lazy(() => import('./pages/Login'));
const Register = lazy(() => import('./pages/Register'));
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'));
@@ -27,8 +35,6 @@ const ProjectPalette = lazy(() => import('./pages/ProjectPalette'));
const ProjectExport = lazy(() => import('./pages/ProjectExport'));
const Profile = lazy(() => import('./pages/Profile'));
const Verify = lazy(() => import('./pages/Verify'));
-const Terms = lazy(() => import('./pages/Terms'));
-const Privacy = lazy(() => import('./pages/Privacy'));
const SharedProject = lazy(() => import('./pages/SharedProject'));
const NotFound = lazy(() => import('./pages/NotFound'));
@@ -38,7 +44,6 @@ const NotFound = lazy(() => import('./pages/NotFound'));
function prefetchRouteChunks() {
const prefetch = () => {
[
- import('./pages/Landing'),
import('./pages/Login'),
import('./pages/Register'),
import('./pages/ForgotPassword'),
@@ -138,80 +143,97 @@ function AppRoutes() {
prefetchRouteChunks();
}, []);
+ return (
+
+
+
+ );
+}
+
+// The routed UI without any router: shared verbatim by the browser entry
+// (BrowserRouter, above) and the build-time prerenderer (StaticRouter, see
+// src/entry-server.jsx), so the prerendered HTML and the hydrating client
+// render exactly the same tree.
+export function AppRouteTree() {
return (
<>
{/* Site-wide decorative cursor follower (self-disables for touch and
reduced-motion users — see the component). */}
-
-
- }>
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
+
+ }>
+
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
-
-
+
+
+
}
- />
- } />
- } />
- } />
- } />
- }>
- } />
- } />
-
-
-
- }
- >
- } />
- } />
- } />
- } />
-
+ >
+ } />
+ } />
+ } />
+ } />
- } />
-
-
-
+
+ } />
+
+
>
);
}
-// Establishes the provider hierarchy (auth first, then projects).
-export default function App() {
+// Establishes the provider hierarchy (auth first, then projects). Exported so
+// the build-time prerenderer wraps the exact same providers around the tree.
+export function AppProviders({ children }) {
return (
-
-
-
+ {children}
);
}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/Seo.jsx b/frontend/src/components/Seo.jsx
index 23927d4..3f4c508 100644
--- a/frontend/src/components/Seo.jsx
+++ b/frontend/src/components/Seo.jsx
@@ -15,9 +15,10 @@ const SITE_URL = (import.meta.env.VITE_SITE_URL || 'https://frameset-taupe.verce
);
const DEFAULT_DESCRIPTION =
"FrameSet keeps every project's graphic standards and color palette in one place.";
-// Brand logo as a safe default that always exists; swap for a dedicated
-// 1200×630 `og-cover.png` for richer link previews.
-const DEFAULT_IMAGE = `${SITE_URL}/FrameSet_Logo.png`;
+// Dedicated 1200×630 cover (brand lockup + tagline + palette strip) so links
+// to any page unfurl with a proper card; shared projects override this with
+// their own server-rendered preview.
+const DEFAULT_IMAGE = `${SITE_URL}/og-cover.png`;
export default function Seo({
title,
diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx
index 1addfc9..4d6d279 100644
--- a/frontend/src/components/ThemeToggle.jsx
+++ b/frontend/src/components/ThemeToggle.jsx
@@ -1,21 +1,30 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import useTheme from '../hooks/useTheme';
// Button that switches between light and dark themes (sun/moon icon).
export default function ThemeToggle({ className = '' }) {
const { theme, toggleTheme } = useTheme();
+ // The icon depends on localStorage, which build-time prerendering can't
+ // know: render it only once mounted so the server HTML and the client's
+ // first render agree (the button keeps its size, so nothing shifts).
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
const isDark = theme === 'dark';
return (