diff --git a/frontend/public/FrameSet_Logo.png b/frontend/public/FrameSet_Logo.png index 0335b1c..f9cdffd 100644 Binary files a/frontend/public/FrameSet_Logo.png and b/frontend/public/FrameSet_Logo.png differ diff --git a/frontend/public/FrameSet_Logo_Reversed.png b/frontend/public/FrameSet_Logo_Reversed.png index 6e49155..53681bd 100644 Binary files a/frontend/public/FrameSet_Logo_Reversed.png and b/frontend/public/FrameSet_Logo_Reversed.png differ diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt new file mode 100644 index 0000000..fdf69e2 --- /dev/null +++ b/frontend/public/llms.txt @@ -0,0 +1,29 @@ +# FrameSet + +> FrameSet is a web app for illustrators and creatives that centralizes the +> graphic references of their projects — color palettes, typography and brush +> specifications — and turns them into shareable, exportable reference sheets. + +## What you can do + +- Build per-project color palettes by hand, with the system eyedropper, by + extracting colors from an image, by generating harmonies, or by importing + Adobe .ase, GIMP/Krita .gpl or Procreate .swatches files. +- Record typography standards (Google Fonts picker) and brush standards + (size, opacity, usage). +- Export a PDF style guide, JSON data, or palette files for drawing apps + (.ase, .gpl, .swatches), and share a public read-only link that updates + live as the project is edited. +- Accounts support email/password or Google sign-in, email verification, + optional TOTP two-factor authentication with recovery codes, and a + read-only demo account to try everything without registering. + +## Pages + +- [Home](/): product overview and features. +- [Register](/register): create an account (a demo is available without one). +- [Terms of Service](/terms) and [Privacy Policy](/privacy). + +Shared reference sheets live under /s/ and are intentionally private +links (not indexed); the REST API under /api is documented for account +holders in the repository. diff --git a/frontend/src/components/Logo.jsx b/frontend/src/components/Logo.jsx index 64c6052..f93cd71 100644 --- a/frontend/src/components/Logo.jsx +++ b/frontend/src/components/Logo.jsx @@ -10,12 +10,15 @@ export default function Logo({ className = '', style }) { alt="FrameSet" className={`${className} dark:hidden`.trim()} style={style} + // Often the page's LCP element (public top bar): fetch it eagerly. + fetchpriority="high" /> ); diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 6fbabf3..e2abcf6 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -27,6 +27,34 @@ export const AuthContext = createContext(null); const SESSION_WARNING_BEFORE_MS = 10 * 60 * 1000; const SESSION_CHECK_INTERVAL_MS = 60 * 1000; +// localStorage flag marking that a session was opened on this browser, so the +// mount-time profile probe can be skipped for plain visitors (who would only +// get a 401). Purely an optimization hint — never trusted as authentication; +// localStorage may be unavailable (private mode), hence the try/catch shells. +const SESSION_HINT_KEY = 'frameset-session'; +const hasSessionHint = () => { + try { + return localStorage.getItem(SESSION_HINT_KEY) === '1'; + } catch { + // Can't read the hint: fall back to always probing, like before. + return true; + } +}; +const setSessionHint = () => { + try { + localStorage.setItem(SESSION_HINT_KEY, '1'); + } catch { + /* cosmetic only */ + } +}; +const clearSessionHint = () => { + try { + localStorage.removeItem(SESSION_HINT_KEY); + } catch { + /* cosmetic only */ + } +}; + export const AuthProvider = ({ children }) => { // Authenticated user (null when logged out). authLoading is true until the // initial session hydration completes, so guards can avoid flashing. @@ -69,6 +97,12 @@ export const AuthProvider = ({ children }) => { // On mount, restore the session from the auth cookies by fetching the profile. // Handles three cases: valid session, no session (401), and expired access // token (403 -> attempt one refresh, then retry the profile fetch once). + // + // The probe only runs when a session hint is present (set below whenever a + // session actually opens): first-time visitors on public pages get zero + // profile request instead of a guaranteed 401 — faster, and no error noise + // in the console. The hint is only ever an optimization signal; the cookies + // remain the single source of truth. useEffect(() => { let isMounted = true; @@ -85,14 +119,25 @@ export const AuthProvider = ({ children }) => { }; const hydrateSession = async () => { + // No hint of a previous session on this browser: skip the probe, the + // visitor is signed out (a stale-cookie edge would just require signing + // in again, exactly like an expired session). + if (!hasSessionHint()) { + setHydratedUser(null); + if (isMounted) setAuthLoading(false); + return; + } + try { // skipTokenRefresh: we handle the 403/refresh flow explicitly below. const profile = await api.get('/users/profile', { skipTokenRefresh: true }); setHydratedUser(profile || null); return; } catch (error) { - // 401: no session at all -> remain logged out. + // 401: no session at all -> remain logged out (and drop the stale hint + // so the next visit skips the probe again). if (error?.status === 401) { + clearSessionHint(); setHydratedUser(null); return; } @@ -148,6 +193,12 @@ export const AuthProvider = ({ children }) => { return () => setSessionExpiredHandler(null); }, [setGlobalError]); + // Any signed-in state (fresh login of any kind, or a successful hydration) + // marks the browser as having a session, so the next visit probes for it. + useEffect(() => { + if (user) setSessionHint(); + }, [user]); + // Whenever the session is renewed anywhere (this provider's own refresh calls, // or the reactive silent refresh inside services/api.js triggered by a random // request hitting a 403), reset the "expires at" estimate the same way. @@ -334,6 +385,7 @@ export const AuthProvider = ({ children }) => { } finally { setUser(null); setGlobalError(null); + clearSessionHint(); sessionExpiresAtRef.current = null; setSessionExpiringSoon(false); } diff --git a/frontend/tests/unit/AuthContext.test.jsx b/frontend/tests/unit/AuthContext.test.jsx index cf35717..355fca4 100644 --- a/frontend/tests/unit/AuthContext.test.jsx +++ b/frontend/tests/unit/AuthContext.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen, waitFor, act, renderHook } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { AuthProvider, useAuth } from '../../src/context/AuthContext'; const { @@ -76,6 +77,15 @@ const buildHttpError = (status, message = 'HTTP error') => { return error; }; +// The mount-time profile probe only runs when the session hint is present: +// unless a test says otherwise, simulate a browser that had a session. +beforeEach(() => { + localStorage.setItem('frameset-session', '1'); +}); +afterEach(() => { + localStorage.clear(); +}); + describe('AuthContext session hydration', () => { beforeEach(() => { mockApiGet.mockReset(); @@ -87,6 +97,57 @@ describe('AuthContext session hydration', () => { mockSetSessionRefreshedHandler.mockClear(); }); + it('skips the profile probe entirely on a browser with no session hint', async () => { + localStorage.clear(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId('auth-loading')).toHaveTextContent('false')); + + expect(mockApiGet).not.toHaveBeenCalled(); + expect(screen.getByTestId('user-email')).toHaveTextContent(''); + }); + + it('drops the hint after a hard 401 so the next visit skips the probe too', async () => { + mockApiGet.mockRejectedValueOnce(buildHttpError(401)); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId('auth-loading')).toHaveTextContent('false')); + + expect(mockApiGet).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('frameset-session')).toBeNull(); + }); + + it('sets the hint when a session opens and clears it on logout', async () => { + localStorage.clear(); + const Actions = () => { + const { login, logout } = useAuth(); + return ( +
+ + +
+ ); + }; + render( + + + , + ); + const user = userEvent.setup(); + + mockApiPost.mockResolvedValueOnce({ success: true, id: 1, name: 'Axelle' }); + await user.click(screen.getByRole('button', { name: 'do-login' })); + await waitFor(() => expect(localStorage.getItem('frameset-session')).toBe('1')); + + mockApiPost.mockResolvedValueOnce({ success: true }); + await user.click(screen.getByRole('button', { name: 'do-logout' })); + await waitFor(() => expect(localStorage.getItem('frameset-session')).toBeNull()); + }); + it('hydrates the user when /profile returns 200', async () => { mockApiGet.mockResolvedValueOnce({ id: 1, email: 'axelle@example.fr' });