diff --git a/tinytorch/site/extra/community/.gitignore b/tinytorch/site/extra/community/.gitignore
new file mode 100644
index 000000000..b5b06f31b
--- /dev/null
+++ b/tinytorch/site/extra/community/.gitignore
@@ -0,0 +1,8 @@
+# Secrets
+tests/e2e/credentials.json
+
+# Playwright
+test-results/
+playwright-report/
+blob-report/
+playwright/.cache/
diff --git a/tinytorch/site/extra/community/app.js b/tinytorch/site/extra/community/app.js
index 9b55e7e61..c2144e8dc 100644
--- a/tinytorch/site/extra/community/app.js
+++ b/tinytorch/site/extra/community/app.js
@@ -1,7 +1,7 @@
import { injectStyles } from './modules/styles.js';
-import { renderLayout, updateNavState } from './modules/ui.js?v=3';
-import { getSession } from './modules/state.js?v=2';
-import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js?v=3';
+import { renderLayout, updateNavState } from './modules/ui.js';
+import { getSession } from './modules/state.js';
+import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js';
import { openProfileModal, closeProfileModal, handleProfileUpdate, geocodeAndSetCoordinates, checkAndAutoUpdateLocation, setupProfileDeleteEvents } from './modules/profile.js';
import { setupCameraEvents } from './modules/camera.js';
import { getBasePath } from './modules/config.js';
@@ -35,38 +35,24 @@ import { getBasePath } from './modules/config.js';
// Initialize profile events
setupProfileDeleteEvents();
- // 2.6 Check for Supabase Session & Verify
- const checkProfile = async (session) => {
- if (!session || window.location.pathname.includes('profile_setup')) return;
-
- const { data: profile } = await supabase
- .from('profiles')
- .select('display_name, institution, location')
- .eq('id', session.user.id)
- .single();
-
- const hasName = profile && profile.display_name;
- const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
- const hasLoc = profile && profile.location;
-
- if (!hasName || !hasInst || !hasLoc) {
- window.location.href = getBasePath() + '/profile_setup.html';
- }
- };
-
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
localStorage.setItem("tinytorch_token", session.access_token);
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
- // Clean URL hash if present (Supabase puts tokens there)
- if (window.location.hash && window.location.hash.includes('access_token')) {
- window.history.replaceState({}, document.title, window.location.pathname + window.location.search);
+ // Clean URL of tokens/code (Supabase puts tokens in hash or code in query)
+ if ((window.location.hash && window.location.hash.includes('access_token')) ||
+ (window.location.search && window.location.search.includes('code='))) {
+ // Remove sensitive parameters while preserving non-sensitive ones
+ const url = new URL(window.location);
+ url.hash = '';
+ url.searchParams.delete('code');
+ url.searchParams.delete('type');
+ window.history.replaceState({}, document.title, url.toString());
}
updateNavState();
- checkProfile(session);
}
// 3. Verify Session (Async)
verifySession();
@@ -82,7 +68,6 @@ import { getBasePath } from './modules/config.js';
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
updateNavState();
- checkProfile(session);
} else if (event === 'SIGNED_OUT') {
localStorage.removeItem("tinytorch_token");
updateNavState();
@@ -171,10 +156,13 @@ import { getBasePath } from './modules/config.js';
const action = params.get('action');
if (action === 'login') {
- localStorage.removeItem("tinytorch_token");
- localStorage.removeItem("tinytorch_refresh_token");
- localStorage.removeItem("tinytorch_user");
- updateNavState();
+ const { isLoggedIn } = getSession();
+ if (!isLoggedIn) {
+ localStorage.removeItem("tinytorch_token");
+ localStorage.removeItem("tinytorch_refresh_token");
+ localStorage.removeItem("tinytorch_user");
+ updateNavState();
+ }
openModal('login');
} else if (action === 'profile') {
const { isLoggedIn } = getSession();
diff --git a/tinytorch/site/extra/community/community.html b/tinytorch/site/extra/community/community.html
index d01d87e7b..5ecd8dc53 100644
--- a/tinytorch/site/extra/community/community.html
+++ b/tinytorch/site/extra/community/community.html
@@ -289,15 +289,18 @@
- 0 Members
+ 0 Members on Map
+
+
+
+ 0 without Location (Lost at Sea)
0 Institutions
-
-
-
Members with Unknown Location
+
+ Note: Members without a location set in their profile are placed at the Sea Station.
@@ -307,7 +310,6 @@
-
- // Handle Window Resize
- window.addEventListener('resize', () => {
- const w = window.innerWidth;
- const h = window.innerHeight;
- svg.attr('width', w).attr('height', h);
- projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
- center[0] = w/2;
- center[1] = h/2;
+
+ // Update static globe background
+ globeGroup.select("path").attr("d", path);
+
redraw();
});
diff --git a/tinytorch/site/extra/community/modules/auth.js b/tinytorch/site/extra/community/modules/auth.js
index c72d0cc75..0e3ca7862 100644
--- a/tinytorch/site/extra/community/modules/auth.js
+++ b/tinytorch/site/extra/community/modules/auth.js
@@ -1,11 +1,8 @@
import { NETLIFY_URL, SUPABASE_URL, SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, getBasePath } from './config.js';
-import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
-import { updateNavState } from './ui.js?v=2';
+import { updateNavState } from './ui.js';
import { closeProfileModal, openProfileModal } from './profile.js';
-import { getSession, forceLogin, clearSession } from './state.js?v=2';
+import { getSession, forceLogin, clearSession, supabase } from './state.js';
-// Initialize Supabase Client
-const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
export { supabase };
export async function signInWithSocial(provider) {
@@ -267,20 +264,51 @@ export async function handleAuth(e) {
authSubmit.innerHTML = '';
try {
- let endpoint, body;
-
- if (currentMode === 'forgot') {
- endpoint = '/api/auth/reset-password';
- body = { email };
- } else {
- endpoint = currentMode === 'login' ? '/api/auth/login' : '/api/auth/signup';
- body = {
+ // Direct Supabase flow for Signup
+ if (currentMode === 'signup') {
+ const redirectUrl = window.location.origin + basePath + '/index.html?action=login&confirmed_email=true';
+ console.log("Requesting signup with redirect to:", redirectUrl);
+
+ const { error } = await supabase.auth.signUp({
email,
password,
- redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
- };
+ options: {
+ emailRedirectTo: redirectUrl
+ }
+ });
+ if (error) throw error;
+
+ closeModal();
+ showMessageModal(
+ 'Check your Email',
+ 'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.'
+ );
+ return;
}
+ // Direct Supabase flow for Password Reset
+ if (currentMode === 'forgot') {
+ const redirectUrl = window.location.origin + basePath + '/index.html?action=reset-password';
+ console.log("Requesting password reset with redirect to:", redirectUrl);
+
+ const { error } = await supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: redirectUrl
+ });
+ if (error) throw error;
+
+ showMessageModal('Reset Link Sent', 'If an account exists, a reset link has been sent.');
+ setMode('login');
+ return;
+ }
+
+ // Keep existing API flow for Login
+ const endpoint = '/api/auth/login';
+ const body = {
+ email,
+ password,
+ redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
+ };
+
const url = `${NETLIFY_URL}${endpoint}`;
const response = await fetch(url, {
@@ -294,72 +322,60 @@ export async function handleAuth(e) {
const data = await response.json();
- if (currentMode === 'forgot') {
- if (response.ok) {
- showMessageModal('Reset Link Sent', data.message || 'If an account exists, a reset link has been sent.');
- setMode('login');
- } else {
- throw new Error(data.error || 'Failed to send reset link');
+ if (!response.ok) {
+ throw new Error(data.error || 'Login failed');
+ }
+
+ if (data.access_token) {
+ localStorage.setItem("tinytorch_token", data.access_token);
+ if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
+ localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
+
+ // Sync Supabase Client so it doesn't trigger SIGNED_OUT
+ if (data.refresh_token) {
+ const { error: sessionError } = await supabase.auth.setSession({
+ access_token: data.access_token,
+ refresh_token: data.refresh_token
+ });
+ if (sessionError) {
+ console.error("Supabase setSession error during login:", sessionError);
}
- } else {
- if (!response.ok) {
- throw new Error(data.error || (currentMode === 'login' ? 'Login failed' : 'Signup failed'));
}
- if (currentMode === 'login') {
- if (data.access_token) {
- localStorage.setItem("tinytorch_token", data.access_token);
- if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
- localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
+ updateNavState();
- // Sync Supabase Client so it doesn't trigger SIGNED_OUT
- if (data.refresh_token) {
- const { error: sessionError } = await supabase.auth.setSession({
- access_token: data.access_token,
- refresh_token: data.refresh_token
- });
- if (sessionError) {
- console.error("Supabase setSession error during login:", sessionError);
- }
- }
+ // Check Profile Completeness immediately
+ const { data: profile } = await supabase
+ .from('profiles')
+ .select('display_name, institution, location')
+ .eq('id', data.user.id)
+ .single();
- updateNavState();
+ const hasName = profile && profile.display_name;
+ const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
+ const hasLoc = profile && profile.location;
- // Check Profile Completeness immediately
- const { data: profile } = await supabase
- .from('profiles')
- .select('display_name, institution, location')
- .eq('id', data.user.id)
- .single();
+ const params = new URLSearchParams(window.location.search);
+ const nextParam = params.get('next');
+
+ closeModal(); // Always close modal on success
- const hasName = profile && profile.display_name;
- const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
- const hasLoc = profile && profile.location;
+ if (nextParam) {
+ // Ensure nextParam is a clean path if it was encoded
+ const cleanNext = decodeURIComponent(nextParam).split('?')[0];
+ window.location.href = cleanNext;
+ return;
+ }
- if (!hasName || !hasInst || !hasLoc) {
- window.location.href = basePath + '/profile_setup.html';
- return;
- }
+ if (!hasName || !hasInst || !hasLoc) {
+ window.location.href = basePath + '/profile_setup.html';
+ return;
+ }
- const params = new URLSearchParams(window.location.search);
- if (params.get('action') === 'profile') {
- closeModal();
- openProfileModal();
- } else {
- window.location.href = basePath + '/dashboard.html';
- }
- }
+ if (params.get('action') === 'profile') {
+ openProfileModal();
} else {
- // Signup Success - Show Message Modal
- // We close the auth modal first so it doesn't overlap
- closeModal();
- showMessageModal(
- 'Check your Email',
- 'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.',
- () => {
- window.location.href = basePath + '/dashboard.html';
- }
- );
+ window.location.href = basePath + '/dashboard.html';
}
}
diff --git a/tinytorch/site/extra/community/modules/candle.js b/tinytorch/site/extra/community/modules/candle.js
index 1a279aa79..2e787f11f 100644
--- a/tinytorch/site/extra/community/modules/candle.js
+++ b/tinytorch/site/extra/community/modules/candle.js
@@ -1,8 +1,17 @@
// --- CANDLE ANIMATION MODULE ---
+const activeLoops = new Set();
+
export function initCandle(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
+ // Prevent multiple loops on the same canvas
+ if (activeLoops.has(canvasId)) {
+ console.log(`Candle loop already running for ${canvasId}, skipping init.`);
+ return;
+ }
+ activeLoops.add(canvasId);
+
const ctx = canvas.getContext("2d");
// Coordinate system setup:
diff --git a/tinytorch/site/extra/community/modules/guard.js b/tinytorch/site/extra/community/modules/guard.js
index 9f025f6fb..34091925f 100644
--- a/tinytorch/site/extra/community/modules/guard.js
+++ b/tinytorch/site/extra/community/modules/guard.js
@@ -1,81 +1,92 @@
-import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, getBasePath } from './config.js';
+import { clearSession, supabase } from './state.js';
-const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
const LOGIN_PAGE = getBasePath() + '/index.html';
const SETUP_PAGE = getBasePath() + '/profile_setup.html';
+const DASHBOARD_PAGE = getBasePath() + '/dashboard.html';
(async function guard() {
- // 0. HYDRATE SESSION (Fix for Direct Email Login)
+ const path = window.location.pathname;
+ // Enhanced path checking for landing page
+ const isOnIndex = path.endsWith('/') || path.endsWith('index.html') || path === getBasePath() || path === getBasePath() + '/';
+ const isOnSetupPage = path.includes('profile_setup.html');
+ const isPublicPage = isOnIndex || path.includes('login') || path.includes('about') || path.includes('contact');
+ const isProtected = !isPublicPage;
+
+ // 0. Get Session
const storedToken = localStorage.getItem("tinytorch_token");
const storedRefresh = localStorage.getItem("tinytorch_refresh_token");
+
if (storedToken && storedRefresh) {
- await supabase.auth.setSession({
- access_token: storedToken,
- refresh_token: storedRefresh
- });
+ try {
+ await supabase.auth.setSession({
+ access_token: storedToken,
+ refresh_token: storedRefresh
+ });
+ } catch (e) {
+ console.warn("Guard: setSession failed", e);
+ }
}
- // 1. Check Session (Supabase Client)
const { data: { session } } = await supabase.auth.getSession();
- let profile = null;
-
- if (session) {
- // 2a. Fetch Profile via Client
- const { data } = await supabase
- .from('profiles')
- .select('display_name, institution, location')
- .eq('id', session.user.id)
- .single();
- profile = data;
- } else {
- // 1b. Fallback: Check Token Manually
- if (!storedToken) {
- // No session, no token -> Redirect
- if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login') && !window.location.pathname.includes('about')) {
- window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
- }
- return;
- }
-
- // Have token, verify via API
- try {
- const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
- headers: { 'Authorization': `Bearer ${storedToken}` }
- });
-
- if (!res.ok) {
- throw new Error("Token invalid");
- }
- const data = await res.json();
- profile = data.profile; // API returns { profile: {...}, completed_modules: [...] }
-
- } catch (e) {
- console.warn("Guard: Token validation failed", e);
- if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login')) {
- window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
- }
- return;
- }
+ if (!session && isProtected) {
+ console.log("๐ง No session on protected page. Redirecting to login...");
+ window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(path);
+ return;
}
- // 3. The Rules
- // Must have ALL three: Name, Institution, Location
+ if (!session) return; // Public page, no session, we are fine.
+
+ // 1. Fetch Profile with Timeout
+ let profile = null;
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ try {
+ // Use the API for profile details
+ const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
+ headers: { 'Authorization': `Bearer ${session.access_token}` },
+ signal: controller.signal
+ });
+ clearTimeout(timeoutId);
+
+ if (res.ok) {
+ const data = await res.json();
+ profile = data.profile;
+ } else if (res.status === 401 || res.status === 404) {
+ // 401: Token expired, 404: User deleted from DB
+ console.warn(`Guard: Session invalid or account deleted (${res.status}). Purging...`);
+ await clearSession();
+
+ if (isProtected) {
+ window.location.href = LOGIN_PAGE + '?action=login';
+ } else {
+ // If on a public page, just reload to clear UI state
+ window.location.reload();
+ }
+ return;
+ }
+ } catch (e) {
+ console.warn("Guard: Profile fetch failed or timed out", e);
+ }
+
+ // 2. The Rules
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
const isComplete = hasName && hasInst && hasLoc;
- const isOnSetupPage = window.location.pathname.includes('profile_setup');
- if (!isComplete && !isOnSetupPage) {
- console.log("๐ง Profile incomplete. Redirecting to setup...");
- window.location.href = SETUP_PAGE;
- }
- else if (isComplete && isOnSetupPage) {
- // If they are done but try to visit setup, send them to dashboard
- window.location.href = getBasePath() + '/dashboard.html';
+ if (isComplete) {
+ if (isOnSetupPage) {
+ console.log("โ
Profile complete. Moving to dashboard...");
+ window.location.href = DASHBOARD_PAGE;
+ }
+ } else {
+ if (!isOnSetupPage && isProtected) {
+ console.log("๐ง Profile incomplete. Redirecting to setup...");
+ window.location.href = SETUP_PAGE;
+ }
}
-
})();
diff --git a/tinytorch/site/extra/community/modules/profile.js b/tinytorch/site/extra/community/modules/profile.js
index ae7e0c611..f0ae9c1cc 100644
--- a/tinytorch/site/extra/community/modules/profile.js
+++ b/tinytorch/site/extra/community/modules/profile.js
@@ -1,5 +1,5 @@
import { SUPABASE_URL, NETLIFY_URL, getBasePath } from './config.js';
-import { forceLogin, getSession } from './state.js?v=2';
+import { forceLogin, getSession, clearSession } from './state.js';
import { initCandle } from './candle.js';
export async function geocodeAndSetCoordinates(location) {
@@ -124,9 +124,7 @@ export function setupProfileDeleteEvents() {
}
alert("Your account has been deleted.");
- localStorage.removeItem("tinytorch_token");
- localStorage.removeItem("tinytorch_refresh_token");
- localStorage.removeItem("tinytorch_user");
+ await clearSession();
window.location.href = getBasePath() + '/index.html';
} catch (error) {
console.error("Delete account error:", error);
diff --git a/tinytorch/site/extra/community/modules/state.js b/tinytorch/site/extra/community/modules/state.js
index 662b663ac..6ea585a30 100644
--- a/tinytorch/site/extra/community/modules/state.js
+++ b/tinytorch/site/extra/community/modules/state.js
@@ -1,4 +1,16 @@
import { getBasePath } from './config.js';
+import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
+import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY } from './config.js';
+
+const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ flowType: 'pkce', // Prefer PKCE for security, or keep 'implicit' if standard for this app
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: true
+ }
+});
+export { supabase };
// State Management
export function getSession() {
@@ -12,10 +24,46 @@ export function getSession() {
return { token, email, isLoggedIn: !!token };
}
-export function clearSession() {
+export async function clearSession() {
+ console.log("๐งน Aggressively clearing session and cookies...");
+ try {
+ await supabase.auth.signOut();
+ } catch (e) {
+ console.warn("Supabase signOut error during clearSession:", e);
+ }
+
+ // 1. Explicitly remove our own keys
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
+ sessionStorage.removeItem("tinytorch_location_checked");
+
+ // 2. Clear all Supabase and auth-related keys from localStorage
+ const keysToRemove = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && (
+ key.includes("supabase") ||
+ key.includes("auth-token") ||
+ key.startsWith("sb-")
+ )) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach(k => localStorage.removeItem(k));
+
+ // 3. Clear all auth-related cookies
+ const cookies = document.cookie.split(";");
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i];
+ const eqPos = cookie.indexOf("=");
+ const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
+ // Clear for all common paths and subdomains
+ document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
+ document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
+ }
+
+ console.log("โจ Session cleared.");
}
export function forceLogin() {
diff --git a/tinytorch/site/extra/community/modules/ui.js b/tinytorch/site/extra/community/modules/ui.js
index 7d5e6fe1f..7f2c4fd2e 100644
--- a/tinytorch/site/extra/community/modules/ui.js
+++ b/tinytorch/site/extra/community/modules/ui.js
@@ -1,5 +1,5 @@
import { getBasePath, NETLIFY_URL } from './config.js';
-import { getSession } from './state.js?v=2';
+import { getSession } from './state.js';
export function updateNavState() {
const { isLoggedIn, email: userEmail } = getSession();
diff --git a/tinytorch/site/extra/community/package-lock.json b/tinytorch/site/extra/community/package-lock.json
new file mode 100644
index 000000000..aadc8413b
--- /dev/null
+++ b/tinytorch/site/extra/community/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "tinytorch-community-tests",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tinytorch-community-tests",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@playwright/test": "^1.42.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/tinytorch/site/extra/community/package.json b/tinytorch/site/extra/community/package.json
new file mode 100644
index 000000000..07c8343c1
--- /dev/null
+++ b/tinytorch/site/extra/community/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "tinytorch-community-tests",
+ "version": "1.0.0",
+ "description": "E2E tests for Tiny Torch Community",
+ "scripts": {
+ "test": "npx playwright test",
+ "test:ui": "npx playwright test --ui"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.42.0"
+ }
+}
diff --git a/tinytorch/site/extra/community/playwright.config.js b/tinytorch/site/extra/community/playwright.config.js
new file mode 100644
index 000000000..665e043c8
--- /dev/null
+++ b/tinytorch/site/extra/community/playwright.config.js
@@ -0,0 +1,21 @@
+const { defineConfig, devices } = require('@playwright/test');
+
+module.exports = defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:8000/community/',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/tinytorch/site/extra/community/profile_setup.html b/tinytorch/site/extra/community/profile_setup.html
index 1bc3bfadd..c3637b885 100644
--- a/tinytorch/site/extra/community/profile_setup.html
+++ b/tinytorch/site/extra/community/profile_setup.html
@@ -307,9 +307,10 @@