From ae0233bbf5fead4c9542d9d940c028c8857e136a Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 08:24:13 -0700 Subject: [PATCH 001/236] Fix edge-label arrow flip + mobile top-bar/captcha overflow (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edge-label directional icon now flips with the edge: edgeLabelPlacement keeps text upright by stripping 180° when the edge points "backward"; carry that lost 180° on the .edge-label-icon so its arrow tracks the real direction (and flips on flip-direction). The main arrowhead already flipped; the label icon didn't. - Mobile top bar: hide the status chip + Collaborators on small screens (the hamburger handles nav there; they were eating the width). Genericize the "Neo4j GraphDB" label → "Graph DB" (core is Neo4j-optional; don't claim Neo4j). - Mobile captcha: CodeCaptcha is now fluid (w-full/max-w-full, p-3 sm:p-5, text-3xl sm:text-5xl) and GuestModeDialog scrolls (max-h-[90vh] overflow-y-auto) so the captcha + Continue button fit narrow screens. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/components/CodeCaptcha.tsx | 6 +++--- .../web/src/components/GuestModeDialog.tsx | 2 +- .../InteractiveGraphVisualization.tsx | 7 +++++++ packages/web/src/pages/Workspace.tsx | 19 ++++++++++--------- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/CodeCaptcha.tsx b/packages/web/src/components/CodeCaptcha.tsx index b02dd7f8..cde20a1f 100644 --- a/packages/web/src/components/CodeCaptcha.tsx +++ b/packages/web/src/components/CodeCaptcha.tsx @@ -398,8 +398,8 @@ export function CodeCaptcha({ }; return ( -
-
+
+
{/* Header */}
@@ -424,7 +424,7 @@ export function CodeCaptcha({ // (panel-centered) sat above it.
What is: -

+

{mathProblem} = ?

diff --git a/packages/web/src/components/GuestModeDialog.tsx b/packages/web/src/components/GuestModeDialog.tsx index 599af899..d1c8b6ca 100644 --- a/packages/web/src/components/GuestModeDialog.tsx +++ b/packages/web/src/components/GuestModeDialog.tsx @@ -31,7 +31,7 @@ export function GuestModeDialog({ isOpen, onClose, onConfirm }: GuestModeDialogP onClick={onClose} >
e.stopPropagation()} >
diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 26435cd8..c18e7236 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3671,6 +3671,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: if (obstacles) { placedLabels.push({ x: placement.x, y: placement.y, width: labelW + 8, height: labelH + 8 }); } + // edgeLabelPlacement keeps text upright by stripping 180° when the edge + // points "backward" — which leaves the directional label icon pointing + // the wrong way after a flip. Carry that lost 180° on the icon so its + // arrow tracks the real edge direction (and flips when the edge flips). + const trueAngle = (Math.atan2(target.y - source.y, target.x - source.x) * 180) / Math.PI; + const iconFlipped = trueAngle > 90 || trueAngle < -90; + d3.select(this).select('.edge-label-icon').attr('transform', iconFlipped ? 'rotate(180)' : null); return `translate(${placement.x},${placement.y}) rotate(${placement.rotation})`; }); }; diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index ced7c1c6..e9826c85 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -221,21 +221,22 @@ export function Workspace() {
- {/* Right Section: Status and Actions */} -
- {/* Neo4j Status Indicator */} + {/* Right Section: Status and Actions — hidden on mobile (the hamburger + handles nav there); these chips otherwise eat the small-screen width. */} +
+ {/* Graph store status (provider-agnostic — core is Neo4j-optional) */}
- {health?.services?.neo4j?.status === 'healthy' ? 'Neo4j GraphDB' : 'Neo4j GraphDB Offline'} + {health?.services?.neo4j?.status === 'healthy' ? 'Graph DB' : 'Graph DB Offline'}
From 7573e90caf6d2c8b5f960ed3de59e49eb7720c69 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 08:58:08 -0700 Subject: [PATCH 002/236] D1-first: stop surfacing Neo4j errors/labels + fix Admin /config crash (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the SPA backend-agnostic so it never shows Neo4j-specific labels/errors or crashes when Neo4j is not the backend (e.g. cloud D1). - useHealthCheck: make services.neo4j and services.mcp optional; base criticalServices on graphql only; guard mcp?/neo4j? status with optional chaining. A missing graph store no longer forces unhealthy/degraded. - useHealthStatus: make services.neo4j/mcp optional; base the unhealthy polling computation on graphql health only (missing neo4j != unhealthy). - Backend page: genericize the hard-coded "Neo4j Graph Database" service row, descriptions, architecture-diagram label, and summary card to provider-neutral wording ("Graph store" / "Graph database"). - Admin page: FIX REAL CRASH — the cloud /config response has no services block, so config.services.* threw a TypeError and white-screened /admin. Harden every access to config?.services?.X?.Y ?? fallback, also in the useSystemConfig getters and interface. Rename the "Neo4j" SVG node and traffic-flow edge/labels to a generic "Graph DB". - InteractiveGraphVisualization: genericize the two error-mapper strings ("ensure Neo4j is running" / "Verify Neo4j database is running") to "ensure/verify the graph backend is reachable". Workspace.tsx left untouched per request (edited separately). Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 4 +- packages/web/src/hooks/useHealthCheck.ts | 16 ++- packages/web/src/hooks/useHealthStatus.ts | 9 +- packages/web/src/hooks/useSystemConfig.ts | 103 +++++++++--------- packages/web/src/pages/Admin.tsx | 18 +-- packages/web/src/pages/Backend.tsx | 30 ++--- 6 files changed, 94 insertions(+), 86 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index c18e7236..77bb5bfa 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4463,7 +4463,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: } if (message.includes('Neo4j')) { - return "Database connection failed. Please ensure Neo4j is running and properly configured."; + return "Database connection failed. Please ensure the graph backend is reachable and properly configured."; } if (message.includes('Enum') && message.includes('cannot represent')) { @@ -4506,7 +4506,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
💡 Quick fixes:
• Run ./start to start the server
• Check if port 4127 is available
-
• Verify Neo4j database is running
+
• Verify the graph backend is reachable
)} diff --git a/packages/web/src/hooks/useHealthCheck.ts b/packages/web/src/hooks/useHealthCheck.ts index c64ff94d..fb51928b 100644 --- a/packages/web/src/hooks/useHealthCheck.ts +++ b/packages/web/src/hooks/useHealthCheck.ts @@ -19,8 +19,8 @@ export interface HealthCheckResult { timestamp: string; services: { graphql: ServiceHealth & { port: number }; - neo4j: ServiceHealth & { uri: string }; - mcp: McpHealth; + neo4j?: ServiceHealth & { uri: string }; + mcp?: McpHealth; }; } @@ -158,11 +158,15 @@ export function useHealthCheck(options: UseHealthCheckOptions = {}) { const getOverallStatus = useCallback((): 'healthy' | 'degraded' | 'unhealthy' | 'unknown' => { if (!health) return 'unknown'; - const criticalServices = [health.services.graphql, health.services.neo4j]; - const hasCriticalIssue = criticalServices.some(s => s.status === 'unhealthy'); - + const criticalServices = [health.services.graphql]; + const hasCriticalIssue = criticalServices.some(s => s?.status === 'unhealthy'); + if (hasCriticalIssue) return 'unhealthy'; - if (health.status === 'degraded' || health.services.mcp.status === 'unhealthy') return 'degraded'; + if ( + health.status === 'degraded' || + health.services.mcp?.status === 'unhealthy' || + health.services.neo4j?.status === 'unhealthy' + ) return 'degraded'; return 'healthy'; }, [health]); diff --git a/packages/web/src/hooks/useHealthStatus.ts b/packages/web/src/hooks/useHealthStatus.ts index c22fda8c..0f2cc55a 100644 --- a/packages/web/src/hooks/useHealthStatus.ts +++ b/packages/web/src/hooks/useHealthStatus.ts @@ -5,8 +5,8 @@ interface HealthStatus { timestamp: string; services: { graphql: { status: string; port: number }; - neo4j: { status: string; uri: string; error?: string }; - mcp: { status: string; port: number; error?: string }; + neo4j?: { status: string; uri: string; error?: string }; + mcp?: { status: string; port: number; error?: string }; }; } @@ -39,8 +39,7 @@ export function useHealthStatus() { const getPollingInterval = () => { if (!health) return 5000; // Initial check every 5 seconds - const hasUnhealthyService = health.services?.neo4j?.status !== 'healthy' || - health.services?.graphql?.status !== 'healthy'; + const hasUnhealthyService = health.services?.graphql?.status !== 'healthy'; // Poll every 5 seconds if unhealthy, 15 seconds if healthy return hasUnhealthyService ? 5000 : 15000; @@ -63,7 +62,7 @@ export function useHealthStatus() { return () => { if (timeoutId) clearTimeout(timeoutId); }; - }, [health?.services?.neo4j?.status, health?.services?.graphql?.status]); + }, [health?.services?.graphql?.status]); return { health, loading, error, refetch: checkHealth }; } \ No newline at end of file diff --git a/packages/web/src/hooks/useSystemConfig.ts b/packages/web/src/hooks/useSystemConfig.ts index d99fb3e9..84a054a7 100644 --- a/packages/web/src/hooks/useSystemConfig.ts +++ b/packages/web/src/hooks/useSystemConfig.ts @@ -1,53 +1,53 @@ import { useState, useEffect, useRef } from 'react'; interface SystemConfig { - timestamp: string; - services: { - api: { - port: number; - protocol: string; - host: string; - path: string; - healthPath: string; + timestamp?: string; + services?: { + api?: { + port?: number; + protocol?: string; + host?: string; + path?: string; + healthPath?: string; }; - web: { - port: number; - protocol: string; - host: string; - path: string; + web?: { + port?: number; + protocol?: string; + host?: string; + path?: string; }; - neo4j: { - uri: string; - port: number; - protocol: string; - host: string; + neo4j?: { + uri?: string; + port?: number; + protocol?: string; + host?: string; }; - mcp: { - port: number; - protocol: string; - host: string; - path: string; + mcp?: { + port?: number; + protocol?: string; + host?: string; + path?: string; }; - proxy: { - enabled: boolean; - httpsPort: number; - httpPort: number; - protocol: string; - host: string; - certPath: string | null; - keyPath: string | null; + proxy?: { + enabled?: boolean; + httpsPort?: number; + httpPort?: number; + protocol?: string; + host?: string; + certPath?: string | null; + keyPath?: string | null; }; }; - tls: { - enabled: boolean; - certPath: string | null; - keyPath: string | null; - httpsPort: number | null; + tls?: { + enabled?: boolean; + certPath?: string | null; + keyPath?: string | null; + httpsPort?: number | null; }; - environment: { - nodeEnv: string; - clientUrl: string; - corsOrigin: string; + environment?: { + nodeEnv?: string; + clientUrl?: string; + corsOrigin?: string; }; } @@ -133,28 +133,31 @@ export function useSystemConfig(options: UseSystemConfigOptions = {}) { // Helper functions to get specific configuration values const getApiUrl = () => { - if (!config) return null; - return `${config.services.api.protocol}://${config.services.api.host}:${config.services.api.port}`; + const api = config?.services?.api; + if (!api) return null; + return `${api.protocol}://${api.host}:${api.port}`; }; const getWebUrl = () => { - if (!config) return null; - return `${config.services.web.protocol}://${config.services.web.host}:${config.services.web.port}`; + const web = config?.services?.web; + if (!web) return null; + return `${web.protocol}://${web.host}:${web.port}`; }; const getProxyUrl = () => { - if (!config || !config.services.proxy.enabled) return null; - return `${config.services.proxy.protocol}://${config.services.proxy.host}:${config.services.proxy.httpsPort}`; + const proxy = config?.services?.proxy; + if (!proxy || !proxy.enabled) return null; + return `${proxy.protocol}://${proxy.host}:${proxy.httpsPort}`; }; const getNeo4jUrl = () => { - if (!config) return null; - return config.services.neo4j.uri; + return config?.services?.neo4j?.uri ?? null; }; const getMcpUrl = () => { - if (!config) return null; - return `${config.services.mcp.protocol}://${config.services.mcp.host}:${config.services.mcp.port}`; + const mcp = config?.services?.mcp; + if (!mcp) return null; + return `${mcp.protocol}://${mcp.host}:${mcp.port}`; }; return { diff --git a/packages/web/src/pages/Admin.tsx b/packages/web/src/pages/Admin.tsx index 10123181..7b2a4e2a 100644 --- a/packages/web/src/pages/Admin.tsx +++ b/packages/web/src/pages/Admin.tsx @@ -1279,7 +1279,7 @@ certbot renew --dry-run - {config ? `HTTPS:${config.services.proxy.httpsPort}` : 'HTTPS:8443'} + {`HTTPS:${config?.services?.proxy?.httpsPort ?? '8443'}`} @@ -1296,10 +1296,10 @@ certbot renew --dry-run - {config ? `HTTP:${config.services.web.port}` : 'HTTP:3127'} + {`HTTP:${config?.services?.web?.port ?? '3127'}`} - {config ? `HTTP:${config.services.api.port}` : 'HTTP:4127'} + {`HTTP:${config?.services?.api?.port ?? '4127'}`} @@ -1321,14 +1321,14 @@ certbot renew --dry-run - {config ? `Neo4j:${config.services.neo4j.port}` : 'Neo4j:7687'} + {`Graph DB:${config?.services?.neo4j?.port ?? '7687'}`} - - {/* Neo4j Database */} + + {/* Graph Database */} - Neo4j + Graph DB Database @@ -1360,7 +1360,7 @@ certbot renew --dry-run
- Traffic Flow: Browser → HTTPS ({config?.services.proxy.httpsPort || '8443'}) → Nginx Proxy → HTTP backends ({config?.services.web.port || '3127'} Web, {config?.services.api.port || '4127'} API) → Neo4j ({config?.services.neo4j.port || '7687'}) + Traffic Flow: Browser → HTTPS ({config?.services?.proxy?.httpsPort || '8443'}) → Nginx Proxy → HTTP backends ({config?.services?.web?.port || '3127'} Web, {config?.services?.api?.port || '4127'} API) → Graph DB ({config?.services?.neo4j?.port || '7687'})
{lastUpdated && ( @@ -1767,7 +1767,7 @@ For more details, see: /docs/tls-ssl-setup.md addDebugMessage(`📊 Status code: ${healthResponse.status}`); addDebugMessage(`🔍 Server status: ${healthData.status}`); addDebugMessage(`💾 GraphQL service: ${healthData.services?.graphql?.status || 'unknown'}`); - addDebugMessage(`🗄️ Neo4j status: ${healthData.services?.neo4j?.status || 'unknown'}`); + addDebugMessage(`🗄️ Graph store status: ${healthData.services?.neo4j?.status || 'unknown'}`); if (healthResponse.ok) { addDebugMessage('✅ Health endpoint test PASSED'); diff --git a/packages/web/src/pages/Backend.tsx b/packages/web/src/pages/Backend.tsx index c71c18cc..7c31d4e5 100644 --- a/packages/web/src/pages/Backend.tsx +++ b/packages/web/src/pages/Backend.tsx @@ -90,24 +90,26 @@ export function Backend() { dependencies: ['GraphQL API Server'] }); - // Neo4j Graph Database + // Graph store const neo4jStatus = healthData.services?.neo4j?.status; let neo4jServiceStatus: 'healthy' | 'degraded' | 'down' = 'down'; - let neo4jDescription = 'Neo4j connection status unknown'; - + let neo4jDescription = 'Graph store connection status unknown'; + if (neo4jStatus === 'healthy') { neo4jServiceStatus = 'healthy'; - neo4jDescription = `Connected to ${healthData.services.neo4j.uri}`; - debug.push(`🗄️ Neo4j: healthy at ${healthData.services.neo4j.uri}`); + neo4jDescription = healthData.services?.neo4j?.uri + ? `Connected to ${healthData.services.neo4j.uri}` + : 'Graph store connected'; + debug.push(`🗄️ Graph store: healthy${healthData.services?.neo4j?.uri ? ` at ${healthData.services.neo4j.uri}` : ''}`); } else if (neo4jStatus === 'unhealthy') { neo4jServiceStatus = 'down'; - const error = healthData.services.neo4j.error || 'Connection failed'; + const error = healthData.services?.neo4j?.error || 'Connection failed'; neo4jDescription = `Connection failed: ${error.substring(0, 100)}${error.length > 100 ? '...' : ''}`; - debug.push(`❌ Neo4j: down - ${error}`); + debug.push(`❌ Graph store: down - ${error}`); } - + services.push({ - name: 'Neo4j Graph Database', + name: 'Graph store', status: neo4jServiceStatus, responseTime: neo4jServiceStatus === 'healthy' ? 8 : undefined, lastChecked: now, @@ -197,7 +199,7 @@ export function Backend() { description: 'Unavailable (depends on GraphQL API)' }, { - name: 'Neo4j Graph Database', + name: 'Graph store', status: 'down', lastChecked: new Date(), description: 'Status unknown (server unreachable)' @@ -586,7 +588,7 @@ export function Backend() { SQLite Auth - Neo4j (Optional) + Graph store MCP Server @@ -713,13 +715,13 @@ export function Backend() {
-

Graph Database

+

Graph database

- {systemHealth.services.find(s => s.name === 'Neo4j Graph Database')?.status === 'healthy' ? 'Online' : 'Offline'} + {systemHealth.services.find(s => s.name === 'Graph store')?.status === 'healthy' ? 'Online' : 'Offline'}

s.name === 'Neo4j Graph Database')?.status === 'healthy' + systemHealth.services.find(s => s.name === 'Graph store')?.status === 'healthy' ? 'text-green-400' : 'text-red-400' }`} />
From 32a8a77b1bd19636d51fb3cad891b4dfddfb9935 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 09:44:19 -0700 Subject: [PATCH 003/236] Mobile experience overhaul: list-default, slim chrome, no Neo4j surface, clean sign-in (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phones now land on a readable card list instead of the cramped graph, the chrome no longer eats most of the screen, and Neo4j-specific strings/errors are gone from the user surface (the app is D1-first / store-agnostic). Workspace - Default to the card list on phone-width screens (matchMedia, persisted to localStorage); desktop still defaults to the graph. - View-tab strip is horizontally scrollable on mobile so all eight tabs stay reachable instead of clipping; tabs are more compact on small screens. - Re-key the status chip and the in-graph connection banner off the GraphQL data layer (the real signal) instead of Neo4j; genericize the copy ("Connected"/"Offline", "Connection Lost") so an unused Neo4j never shows a scary "Database Connection Lost / Neo4j is not available". - Hide the mini-map on mobile (it covered ~18% of a phone screen). - Node inspector renders as a bottom sheet on mobile instead of being hidden. - Slimmer header padding on small screens. Layout / shell - Mobile drawer is full-width with labels (Workspace, Ontology, …) instead of unlabeled icons; guest indicator shows in the drawer. - Dynamic viewport height (dvh) + safe-area insets (viewport-fit=cover) so the app fills the visible area under collapsing mobile browser bars / notches. View components - CardView / ViewManager toolbar use tighter mobile padding. - Project Health floating panel (320px) is desktop-only; it covered phones. - Remove a per-row console.log spamming the table render. Sign-in / sign-up - Hide the OAuth block entirely when no provider is configured, so the dev hint "Run: ./scripts/setup-oauth.sh" never reaches end users; neutral label otherwise. Responsive header/form padding for narrow screens. Tests - tests/e2e/mobile-experience.spec.ts: phone lands on a list (no graph canvas), no Neo4j/DB-down strings, no horizontal overflow, all eight tabs reachable, mobile graph view is mini-map-free with no connection banner. Verified: web typecheck + prod build clean; new mobile spec green; smoke gate 4/5 (the grow-flow case fails identically on clean develop against this local DB — a pre-existing data-state issue, not from these changes; touches no graph-canvas code). Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/index.html | 2 +- packages/web/src/components/CardView.tsx | 2 +- packages/web/src/components/Layout.tsx | 44 +++---- packages/web/src/components/NodeInspector.tsx | 2 +- packages/web/src/components/TableView.tsx | 8 +- packages/web/src/components/ViewManager.tsx | 13 +- packages/web/src/index.css | 21 +++ packages/web/src/pages/Signin.tsx | 20 ++- packages/web/src/pages/Signup.tsx | 19 ++- packages/web/src/pages/Workspace.tsx | 120 +++++++++++------- tests/e2e/mobile-experience.spec.ts | 65 ++++++++++ 11 files changed, 215 insertions(+), 101 deletions(-) create mode 100644 tests/e2e/mobile-experience.spec.ts diff --git a/packages/web/index.html b/packages/web/index.html index 01f6a8d2..54bca434 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -5,7 +5,7 @@ - + GraphDone - Project Management Reimagined diff --git a/packages/web/src/components/CardView.tsx b/packages/web/src/components/CardView.tsx index 4e44c174..291f2260 100644 --- a/packages/web/src/components/CardView.tsx +++ b/packages/web/src/components/CardView.tsx @@ -109,7 +109,7 @@ const getContributorAvatar = (contributor?: string) => { const CardView: React.FC = ({ filteredNodes, handleEditNode, edges }) => { return ( -
+
{[...filteredNodes] .sort((a, b) => { const dateA = new Date(a.updatedAt || a.createdAt).getTime(); diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index 76757289..03dc498e 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -32,7 +32,7 @@ export function Layout({ children }: LayoutProps) { return (
{/* Mobile menu button */} -
+
- {/* Right Sidebar - Overlay */} + {/* Right Sidebar - Overlay (desktop only) */} {showProjectHealth && ( -
+
)} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 3a78a08e..8f668025 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -688,6 +688,27 @@ input[type="date"]:focus { input, textarea, [contenteditable], .select-text { user-select: text !important; } + + /* Hide the scrollbar on horizontally-scrollable strips (mobile view tabs etc.) + while keeping them swipe-scrollable. */ + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Dynamic viewport height: fills the visible area on mobile browsers, accounting + for the collapsing address bar (falls back to vh where dvh is unsupported). */ + .h-dvh-screen { + height: 100vh; + height: 100dvh; + } + + /* Safe-area insets for notched devices (requires viewport-fit=cover). */ + .pt-safe { padding-top: env(safe-area-inset-top); } + .pb-safe { padding-bottom: env(safe-area-inset-bottom); } } @keyframes fadeIn { diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index b3c2cdb9..b40708d5 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -426,16 +426,20 @@ export function Signin() { {/* Header */}
- GraphDone Logo - GraphDone + GraphDone Logo + GraphDone

Welcome Back

Enter your credentials to join the team

{/* Login Form */} -
- {/* Social Login Buttons */} + + {/* Social Login Buttons — only when at least one provider is actually + configured; otherwise the whole block (and its divider) is hidden so + no setup hints leak to end users (cloud uses email + guest). */} + {(oauthConfig?.google?.configured || oauthConfig?.linkedin?.configured || oauthConfig?.github?.configured) && ( + <>
@@ -482,7 +486,7 @@ export function Signin() { ? 'text-teal-400 bg-gray-900/95 border border-teal-500/30' : 'text-gray-300 bg-gray-900/95 border border-gray-700/50' }`}> - {oauthConfig?.linkedin.configured ? 'Sign in with LinkedIn' : 'Run: ./scripts/setup-oauth.sh'} + {oauthConfig?.linkedin.configured ? 'Sign in with LinkedIn' : 'Not configured'} @@ -503,7 +507,7 @@ export function Signin() { ? 'text-teal-400 bg-gray-900/95 border border-teal-500/30' : 'text-gray-300 bg-gray-900/95 border border-gray-700/50' }`}> - {oauthConfig?.github.configured ? 'Sign in with GitHub' : 'Run: ./scripts/setup-oauth.sh'} + {oauthConfig?.github.configured ? 'Sign in with GitHub' : 'Not configured'}
@@ -516,6 +520,8 @@ export function Signin() { Or sign in with your credentials
+ + )} {/* Sign In Method Toggle */}
diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx index 0fcaaa04..3414472e 100644 --- a/packages/web/src/pages/Signup.tsx +++ b/packages/web/src/pages/Signup.tsx @@ -295,8 +295,8 @@ export function Signup() { {/* Header */}
- GraphDone Logo - GraphDone + GraphDone Logo + GraphDone

Create Your Account

Join the decentralized project management revolution

@@ -364,8 +364,11 @@ export function Signup() {
) : ( - - {/* Social Signup Buttons */} + + {/* Social Signup Buttons — only when at least one provider is configured; + otherwise the whole block (and divider) is hidden so no setup hints leak. */} + {(oauthConfig?.google?.configured || oauthConfig?.linkedin?.configured || oauthConfig?.github?.configured) && ( + <>
@@ -412,7 +415,7 @@ export function Signup() { ? 'text-teal-400 bg-gray-900/95 border border-teal-500/30' : 'text-gray-300 bg-gray-900/95 border border-gray-700/50' }`}> - {oauthConfig?.linkedin.configured ? 'Sign up with LinkedIn' : 'Run: ./scripts/setup-oauth.sh'} + {oauthConfig?.linkedin.configured ? 'Sign up with LinkedIn' : 'Not configured'} @@ -433,7 +436,7 @@ export function Signup() { ? 'text-teal-400 bg-gray-900/95 border border-teal-500/30' : 'text-gray-300 bg-gray-900/95 border border-gray-700/50' }`}> - {oauthConfig?.github.configured ? 'Sign up with GitHub' : 'Run: ./scripts/setup-oauth.sh'} + {oauthConfig?.github.configured ? 'Sign up with GitHub' : 'Not configured'}
@@ -446,6 +449,8 @@ export function Signup() { Or sign up with your credentials
+ + )} {/* Name Field */}
diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index e9826c85..dcf463db 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; @@ -25,7 +25,16 @@ export function Workspace() { const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false); const [showGraphSelectionModal, setShowGraphSelectionModal] = useState(false); const [graphToEdit, setGraphToEdit] = useState(null); - const [viewMode, setViewMode] = useState<'graph' | 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'>('graph'); + const [viewMode, setViewMode] = useState<'graph' | 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'>(() => { + if (typeof window === 'undefined') return 'graph'; + const saved = window.localStorage.getItem('graphdone:viewMode'); + if (saved) return saved as any; + // On the go: a phone-sized screen lands on the readable card list, not the graph. + return window.matchMedia('(max-width: 767px)').matches ? 'cards' : 'graph'; + }); + const [isMobile, setIsMobile] = useState(() => + typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + ); const [showMiniMap, setShowMiniMap] = useState(true); const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); const breadcrumb = getBreadcrumb(); @@ -33,6 +42,17 @@ export function Workspace() { const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); + useEffect(() => { + try { window.localStorage.setItem('graphdone:viewMode', viewMode); } catch { /* private mode */ } + }, [viewMode]); + + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + // Get real-time counts for header display const { data: workItemsData } = useQuery(GET_WORK_ITEMS, { variables: currentGraph ? { @@ -66,9 +86,9 @@ export function Workspace() { return (
{/* Header with Graph Context */} -
+
{/* Responsive Layout Container */} -
+
{/* Left Section: Graph Selector */}
@@ -103,118 +123,119 @@ export function Workspace() {
- {/* Center Section: View Mode Buttons (Always Centered) */} -
-
+ {/* Center Section: View Mode Buttons — horizontally scrollable on mobile so + all eight stay reachable without clipping on a phone-width screen. */} +
+
`; + +const out = path.join(ROOT, 'index.html'); +fs.writeFileSync(out, html); +console.log(`Matrix gallery: ${out}`); +console.log(` ${features.length} features × ${viewports.length} resolutions = ${total} screenshots`); From 5bafeef8e56e390f45f6ef91f8438551b93f5e59 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 00:20:58 -0700 Subject: [PATCH 018/236] test(z-order): comprehensive stacking gate across every overlay + page (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(z-order): comprehensive stacking gate across every overlay + page Adds tests/e2e/z-order.spec.ts (@zorder) + tests/helpers/zorder.ts: an elementFromPoint-based auditor that opens every floating overlay and asserts it is genuinely the topmost element across its area. Covers, on desktop and phone: the graph-selector + user-menu dropdowns, all four workspace filter dropdowns, the work-item details / create-work-item / create-graph modals, the Type/Status dropdowns nested INSIDE the details modal, the on-canvas node expand peek, the node context menu, the Ontology type-detail modal, the Settings visual-quality dropdown, the Admin create-user modal, toasts firing over an open modal, and the mobile More sheet. Real stacking bugs this class found and fixes: - ViewManager filter bar: backdrop-blur created a stacking context that trapped the filter dropdowns' z-50 behind the content; lift with explicit relative z-50 on the bar. - Toasts (NotificationContext): raised above modals so an alert is never hidden behind an open dialog. Adds stable data-testids to overlay panels that lacked them (graph-selector dropdown, user-menu dropdown, CustomDropdown menu, details Type/Status badges+dropdowns, Ontology/Admin modals, node context menu) plus a harmless window.__notify test hook to fire a toast on demand. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: point triggers at dev (was develop) after the branch rename The develop→dev rename left ci.yml and comprehensive-tests.yml triggering on a branch that no longer exists, so no workflow ran on dev or its PRs. Update the push/pull_request branch filters to [main, dev]. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +- .github/workflows/comprehensive-tests.yml | 4 +- .../src/components/AdminUserManagement.tsx | 2 +- .../web/src/components/CustomDropdown.tsx | 2 +- packages/web/src/components/GraphSelector.tsx | 1 + .../InteractiveGraphVisualization.tsx | 1 + packages/web/src/components/UserSelector.tsx | 2 +- packages/web/src/components/ViewManager.tsx | 5 +- .../src/components/WorkItemDetailsModal.tsx | 6 +- .../web/src/contexts/NotificationContext.tsx | 14 +- packages/web/src/pages/Ontology.tsx | 2 +- packages/web/src/pages/Settings.tsx | 24 +- tests/e2e/z-order.spec.ts | 289 ++++++++++++++++++ tests/helpers/zorder.ts | 92 ++++++ 14 files changed, 424 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/z-order.spec.ts create mode 100644 tests/helpers/zorder.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4a4b7a..7ae18c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, develop] + branches: [main, dev] pull_request: - branches: [main, develop] + branches: [main, dev] schedule: - cron: '0 3 * * *' # nightly full prod-stack smoke workflow_dispatch: diff --git a/.github/workflows/comprehensive-tests.yml b/.github/workflows/comprehensive-tests.yml index c6d1b83b..fe89a24d 100644 --- a/.github/workflows/comprehensive-tests.yml +++ b/.github/workflows/comprehensive-tests.yml @@ -7,9 +7,9 @@ name: Comprehensive Test Suite on: push: - branches: [main, develop] + branches: [main, dev] pull_request: - branches: [main, develop] + branches: [main, dev] schedule: - cron: '0 2 * * *' # daily 02:00 UTC workflow_dispatch: diff --git a/packages/web/src/components/AdminUserManagement.tsx b/packages/web/src/components/AdminUserManagement.tsx index cb5e93ef..31752e08 100644 --- a/packages/web/src/components/AdminUserManagement.tsx +++ b/packages/web/src/components/AdminUserManagement.tsx @@ -661,7 +661,7 @@ export function AdminUserManagement() { {/* Create User Modal */} {showCreateUser && (
-
+

Create New User

diff --git a/packages/web/src/components/CustomDropdown.tsx b/packages/web/src/components/CustomDropdown.tsx index c787163e..a13982d5 100644 --- a/packages/web/src/components/CustomDropdown.tsx +++ b/packages/web/src/components/CustomDropdown.tsx @@ -78,7 +78,7 @@ export function CustomDropdown({ {/* Dropdown Menu */} {isOpen && ( -
+
{options.map((option) => ( {isTypeDropdownOpen && ( -
+
{typeOptions.map((option, index) => ( {isStatusDropdownOpen && ( -
+
{statusOptions.map((option, index) => ( {isPriorityDropdownOpen && ( -
+
{priorityOptions.map((option, index) => ( {isContributorDropdownOpen && ( -
+
{contributorOptions.map((option, index) => (
@@ -275,7 +275,7 @@ class EmailService {
diff --git a/packages/server/src/services/onboarding.ts b/packages/server/src/services/onboarding.ts index 99ddbec2..afee1e98 100644 --- a/packages/server/src/services/onboarding.ts +++ b/packages/server/src/services/onboarding.ts @@ -46,7 +46,7 @@ export const WELCOME_NODES: OnboardingNode[] = [ title: 'Welcome to GraphDone!', description: `# Welcome to GraphDone! 🎉 -GraphDone is a graph-native project management system that reimagines how work flows through dependencies rather than hierarchies. +GraphDone is a graph-native management system that reimagines how work flows through dependencies rather than hierarchies. ## Key Concepts: - **Nodes** represent work items, ideas, tasks, or outcomes diff --git a/packages/web/index.html b/packages/web/index.html index c35af21c..fd050645 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -7,8 +7,8 @@ - GraphDone - Project Management Reimagined - + GraphDone - Management, Reimagined +
diff --git a/packages/web/public/manifest.json b/packages/web/public/manifest.json index 1d1d1460..4f8836cf 100644 --- a/packages/web/public/manifest.json +++ b/packages/web/public/manifest.json @@ -1,7 +1,7 @@ { "name": "GraphDone", "short_name": "GraphDone", - "description": "Graph-native project management system", + "description": "Graph-native management system", "start_url": "/", "display": "standalone", "background_color": "#ffffff", diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx index 943d6e74..86e18738 100644 --- a/packages/web/src/pages/Signup.tsx +++ b/packages/web/src/pages/Signup.tsx @@ -299,7 +299,7 @@ export function Signup() { GraphDone

Create Your Account

-

Join the decentralized project management revolution

+

Join the decentralized management revolution

{/* Email Verification Screen or Signup Form */} diff --git a/packages/web/src/types/projectData.ts b/packages/web/src/types/projectData.ts index f1b7c90f..c4d93333 100644 --- a/packages/web/src/types/projectData.ts +++ b/packages/web/src/types/projectData.ts @@ -1,4 +1,4 @@ -// Project management specific types and mock data +// Management specific types and mock data export type RelationshipType = | 'DEPENDS_ON' // This node depends on another to be completed @@ -50,7 +50,7 @@ export interface MockEdge { createdAt: string; } -// Mock project management data +// Mock management data export const mockProjectNodes: MockNode[] = [ { id: 'epic-1', From 894f1e7d359dd6f5fe2787997867105e20e478f7 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 14:26:04 -0700 Subject: [PATCH 025/236] feat(auth): passwordless /signup via magic-link (drop broken credential form) (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential Signup form called signup/checkAvailability/resendVerificationEmail GraphQL ops the cloud Worker never implemented (Unknown type "SignupInput" etc.), so /signup was non-functional. Passwordless makes sign-up and sign-in the SAME action (email → link → account, new or returning), so: - /signup now opens Signin's working magic-link flow (new initialMagicLink prop). - Deleted the dead 740-line Signup.tsx (no longer imported). - Magic-link success screen states you'll stay signed in on this device (keep-me-logged-in: the Worker now mints a long-lived USER token). Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/App.tsx | 5 +- packages/web/src/pages/Signin.tsx | 7 +- packages/web/src/pages/Signup.tsx | 741 ------------------------------ 3 files changed, 8 insertions(+), 745 deletions(-) delete mode 100644 packages/web/src/pages/Signup.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 9c8e0c54..221943e1 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -8,7 +8,6 @@ import { Settings } from './pages/Settings'; import { Admin } from './pages/Admin'; import { Backend } from './pages/Backend'; import { Signin } from './pages/Signin'; -import { Signup } from './pages/Signup'; import { ForgotPassword } from './pages/ForgotPassword'; import { ResetPassword } from './pages/ResetPassword'; import { InteractiveGraphVisualization } from './components/InteractiveGraphVisualization'; @@ -86,7 +85,9 @@ function AuthenticatedApp() { } /> } /> - } /> + {/* Passwordless: signing up and signing in are the same action (email → link → + account, new or returning). /signup opens the magic-link flow directly. */} + } /> } /> } /> } /> diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index f0f49caf..823bf719 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -79,7 +79,7 @@ const GET_SYSTEM_SETTINGS = gql` } `; -export function Signin() { +export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolean } = {}) { const navigate = useNavigate(); const { login: setAuthUser } = useAuth(); const [searchParams] = useSearchParams(); @@ -93,7 +93,7 @@ export function Signin() { const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState(null); const [showPassword, setShowPassword] = useState(false); - const [useMagicLink, setUseMagicLink] = useState(false); + const [useMagicLink, setUseMagicLink] = useState(initialMagicLink); const [magicLinkSent, setMagicLinkSent] = useState(false); const [magicLinkLoading, setMagicLinkLoading] = useState(false); const [errors, setErrors] = useState>({}); @@ -582,6 +582,9 @@ export function Signin() {

📂 Don't see it? Check your spam folder after 3 minutes

+

+ ✅ Once you click it you'll stay signed in on this device — no need to log in again +

diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx deleted file mode 100644 index 86e18738..00000000 --- a/packages/web/src/pages/Signup.tsx +++ /dev/null @@ -1,741 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useMutation, gql } from '@apollo/client'; -import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react'; -import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { PasswordRequirements } from '../components/PasswordRequirements'; -import { isValidEmail, getPasswordStrength } from '../utils/validation'; -import { CodeCaptcha } from '../components/CodeCaptcha'; - -const SIGNUP_MUTATION = gql` - mutation Signup($input: SignupInput!) { - signup(input: $input) { - token - user { - id - email - username - name - role - isEmailVerified - } - } - } -`; - -const CHECK_AVAILABILITY = gql` - query CheckAvailability($email: String, $username: String) { - checkAvailability(email: $email, username: $username) { - success - message - } - } -`; - -const RESEND_VERIFICATION_EMAIL = gql` - mutation ResendVerificationEmail($email: String!) { - resendVerificationEmail(email: $email) { - success - message - } - } -`; - -export function Signup() { - const navigate = useNavigate(); - - const [formData, setFormData] = useState({ - email: '', - username: '', - password: '', - confirmPassword: '', - name: '' - }); - const [captchaPayload, setCaptchaPayload] = useState(null); - - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [errors, setErrors] = useState>({}); - const [isChecking, setIsChecking] = useState>({}); - const [availability, setAvailability] = useState>({}); - const [emailValid, setEmailValid] = useState(null); - const [passwordsMatch, setPasswordsMatch] = useState(null); - const [signupComplete, setSignupComplete] = useState(false); - const [resendLoading, setResendLoading] = useState(false); - const [resendMessage, setResendMessage] = useState(''); - const [resendCooldown, setResendCooldown] = useState(0); - const [rateLimitError, setRateLimitError] = useState(null); - const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState(null); - const [oauthConfig, setOauthConfig] = useState<{ - google: { enabled: boolean; configured: boolean }; - github: { enabled: boolean; configured: boolean }; - linkedin: { enabled: boolean; configured: boolean }; - } | null>(null); - - const [signup, { loading }] = useMutation(SIGNUP_MUTATION, { - onCompleted: (data) => { - setSignupComplete(true); - }, - onError: (error) => { - if (error.graphQLErrors?.[0]?.extensions?.rateLimitExceeded) { - const retryAfter = error.graphQLErrors[0].extensions.retryAfter as number; - setRateLimitError(error.message); - setRateLimitRetryAfter(retryAfter); - } else { - setErrors({ submit: error.message }); - } - } - }); - - const [resendVerificationEmail] = useMutation(RESEND_VERIFICATION_EMAIL, { - onCompleted: (data) => { - setResendLoading(false); - if (data.resendVerificationEmail.success) { - setResendMessage('Verification email sent! Check your inbox.'); - } else { - setResendMessage(data.resendVerificationEmail.message || 'Failed to send email. Please try again.'); - } - }, - onError: () => { - setResendLoading(false); - setResendMessage('Failed to send email. Please try again.'); - } - }); - - const checkAvailability = async (field: 'email' | 'username', value: string) => { - if (!value) return; - - setIsChecking({ ...isChecking, [field]: true }); - - try { - const response = await fetch('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: CHECK_AVAILABILITY, - variables: { [field]: value } - }) - }); - - const data = await response.json(); - if (data.data?.checkAvailability) { - setAvailability({ - ...availability, - [field]: data.data.checkAvailability.success - }); - - if (!data.data.checkAvailability.success) { - setErrors({ - ...errors, - [field]: data.data.checkAvailability.message - }); - } else { - const newErrors = { ...errors }; - delete newErrors[field]; - setErrors(newErrors); - } - } - } finally { - setIsChecking({ ...isChecking, [field]: false }); - } - }; - - const validateForm = () => { - const newErrors: Record = {}; - - // Email validation - if (!formData.email) { - newErrors.email = 'Email is required'; - } else if (!isValidEmail(formData.email)) { - newErrors.email = 'Invalid email format'; - } - - // Username validation - if (!formData.username) { - newErrors.username = 'Username is required'; - } else if (formData.username.length < 3) { - newErrors.username = 'Username must be at least 3 characters'; - } else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) { - newErrors.username = 'Username can only contain letters, numbers, _ and -'; - } - - // Name validation - if (!formData.name) { - newErrors.name = 'Name is required'; - } - - // Password validation - if (!formData.password) { - newErrors.password = 'Password is required'; - } else if (formData.password.length < 8) { - newErrors.password = 'Password must be at least 8 characters'; - } - - // Confirm password validation - if (formData.password !== formData.confirmPassword) { - newErrors.confirmPassword = 'Passwords do not match'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) { - return; - } - - await signup({ - variables: { - input: { - email: formData.email, - username: formData.username, - password: formData.password, - name: formData.name, - captchaPayload: captchaPayload - } - } - }); - }; - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData({ ...formData, [name]: value }); - - if (name === 'email') { - if (value.length === 0) { - setEmailValid(null); - } else { - setEmailValid(isValidEmail(value)); - } - } - - if (name === 'password' || name === 'confirmPassword') { - const pwd = name === 'password' ? value : formData.password; - const confirmPwd = name === 'confirmPassword' ? value : formData.confirmPassword; - - if (pwd && confirmPwd) { - setPasswordsMatch(pwd === confirmPwd); - } else { - setPasswordsMatch(null); - } - } - - if (errors[name]) { - const newErrors = { ...errors }; - delete newErrors[name]; - setErrors(newErrors); - } - }; - - const handleBlur = (field: 'email' | 'username') => { - if (formData[field]) { - checkAvailability(field, formData[field]); - } - }; - - const handleResendVerificationEmail = async () => { - setResendLoading(true); - setResendMessage(''); - - await resendVerificationEmail({ - variables: { - email: formData.email - } - }); - setResendCooldown(60); - }; - - useEffect(() => { - const fetchOAuthConfig = async () => { - try { - const apiUrl = import.meta.env.VITE_API_URL || 'https://localhost:4128'; - const response = await fetch(`${apiUrl}/config`); - if (response.ok) { - const config = await response.json(); - if (config.oauth && config.oauth.providers) { - setOauthConfig(config.oauth.providers); - } - } - } catch { - console.debug('OAuth config unavailable; hiding social sign-up.'); - } - }; - fetchOAuthConfig(); - }, []); - - useEffect(() => { - if (resendCooldown > 0) { - const timer = setTimeout(() => setResendCooldown(prev => prev - 1), 1000); - return () => clearTimeout(timer); - } - return undefined; - }, [resendCooldown]); - - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !loading && !Object.values(isChecking).some(checking => checking)) { - const submitEvent = e as unknown as React.FormEvent; - void handleSubmit(submitEvent); - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [formData, loading, isChecking, handleSubmit]); - - const passwordStrength = getPasswordStrength(formData.password); - - return ( -
- {/* Static gradient background - optimized for all browsers */} -
-
- {/* Header */} -
- - GraphDone Logo - GraphDone - -

Create Your Account

-

Join the decentralized management revolution

-
- - {/* Email Verification Screen or Signup Form */} - {signupComplete ? ( -
-
-
- -
-

Check Your Email!

-

- We've sent a verification link to {formData.email} -

-

- Click the link in the email to verify your account and complete registration. The link expires in 24 hours. -

- - - - {resendMessage && ( -

- {resendMessage} -

- )} -
- -
-

- Didn't receive the email? Check your spam folder or click the button above to resend. -

-
- -
- - Back to login - -
-
- ) : ( - - {/* Social Signup Buttons — only when at least one provider is configured; - otherwise the whole block (and divider) is hidden so no setup hints leak. */} - {(oauthConfig?.google?.configured || oauthConfig?.linkedin?.configured || oauthConfig?.github?.configured) && ( - <> -
- - - - - -
- -
-
-
-
-
- Or sign up with your credentials -
-
- - )} - - {/* Name Field */} -
- - - {errors.name && } -
- - {/* Email Field */} -
- -
- handleBlur('email')} - autoComplete="email" - className={`w-full px-4 py-3 bg-gray-700/50 backdrop-blur-sm border rounded-xl text-gray-100 focus:outline-none focus:ring-2 pr-10 transition-all ${ - emailValid === false - ? 'border-red-500/50 focus:ring-red-500/50' - : emailValid === true - ? 'border-teal-500/50 focus:ring-teal-500/50 focus:border-teal-500/50' - : errors.email - ? 'border-red-500/50 focus:ring-red-500/50' - : 'border-gray-600/50 focus:ring-teal-500/50 focus:border-teal-500/50' - }`} - placeholder="john@example.com" - /> - {isChecking.email ? ( -
-
-
- ) : emailValid !== null ? ( -
- {emailValid ? ( - - ) : ( - - )} -
- ) : availability.email && !isChecking.email ? ( - - ) : null} -
- {errors.email &&

{errors.email}

} -
- - {/* Username Field */} -
- -
- handleBlur('username')} - autoComplete="username" - className={`w-full px-4 py-3 bg-gray-700/50 backdrop-blur-sm border rounded-xl text-gray-100 focus:outline-none focus:ring-2 pr-10 transition-all ${ - errors.username ? 'border-red-500/50 focus:ring-red-500/50' : 'border-gray-600/50 focus:ring-teal-500/50 focus:border-teal-500/50' - }`} - placeholder="johndoe" - /> - {isChecking.username && ( -
-
-
- )} - {availability.username && !isChecking.username && ( - - )} -
- {errors.username &&

{errors.username}

} - {!errors.username && ( -

- - 3-20 characters, letters, numbers, _ and - only -

- )} -
- - {/* Password Field */} -
- -
- - -
- {errors.password &&

{errors.password}

} - - {/* Password Strength Indicator */} - {formData.password && ( -
-
- Password strength: - {passwordStrength.label} -
-
-
-
-
- )} - - -
- - {/* Confirm Password Field */} -
- -
- - {passwordsMatch !== null && formData.confirmPassword && ( -
- {passwordsMatch ? ( - - ) : ( - - )} -
- )} - -
- {errors.confirmPassword &&

{errors.confirmPassword}

} - {passwordsMatch === false && formData.confirmPassword && !errors.confirmPassword && ( -

Passwords do not match

- )} - {passwordsMatch === true && formData.confirmPassword && ( -

Passwords match!

- )} -
- - {/* CAPTCHA */} -
- setCaptchaPayload(code)} - className="w-full" - /> -
- - {/* Rate Limit Error */} - {rateLimitError && ( -
-
- -
-

- 🛡️ Rate Limit Exceeded -

-

- {rateLimitError} -

- {rateLimitRetryAfter && ( -

- Please try again in {Math.ceil(rateLimitRetryAfter / 60)} minute(s). -

- )} -
-
-
- )} - - {/* Submit Error */} - {errors.submit && !rateLimitError && ( -
-

{errors.submit}

-
- )} - - {/* Submit Button */} - - - {/* Terms */} -

- By creating an account, you agree to participate in
- the decentralized graph network and contribute
- to the collective intelligence. -

- - )} - - {!signupComplete && ( - <> - {/* Login Link */} -
-

- Already have an account?{' '} - - Sign in - -

-
- - )} -
- - {/* Insecure-connection warning (top strip, only over HTTP) */} - -
- ); -} \ No newline at end of file From fd4585fc340c6f8beb67be9e8dcbbe9abdcd1f81 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 16:19:30 -0700 Subject: [PATCH 026/236] VIEW-1: graph camera centering + session persistence + zoom-extents button (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * VIEW-1: graph camera centering, session persistence + zoom-extents button Camera no longer loads off-screen then jumps in, and the user's view is respected within and across a session instead of being reset on every visit. - Once-per-graph framing guarded by fittedGraphsRef: on first load of a graph, restore the user's saved camera (localStorage graphdone:camera:) or auto-fit ONCE so the graph is framed, never re-fitting a graph already framed (don't fight the user's pan/zoom). - Fit runs through fitViewRef (not the fitViewToNodes callback) so the timer isn't cancelled by the callback's identity churning during physics settle — the bug that left graphs rendered off-screen. - Debounced (600ms) per-graph camera save on pan/zoom — localStorage only, no D1 chatter. (Shared node-position sync to D1 is a separate, server-side item.) - Zoom-extents button (Maximize2) on graph views: under the lock toggle on phones, top-left on desktop (the right corner is the docked inspector). Exposes triggerZoomToFit -> fitViewRef, framing every node without touching the camera-restore state. Tests: tests/e2e/camera.spec.ts @camera (3/3) — loads framed, zoom-extents reframes after a hard zoom-in, pan persists across reload. THE GATE 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) * VIEW-1: promote graph-balance metrics to a pass/fail gate The camera-centering fix lands the graph framed + centred (offMag ~0.05, bbox coverage ~0.93 across desktop/laptop/tablet). Turn the OpenCV balance spec from measurement-only into a gate with wide margins over that good state: - contentPixels > 200 (graph is on-screen, not blank) - bbox coverage > 0.35 (framed, not cornered; pre-fix off-screen read ~0.06) - offMag < 0.30 (centred; pre-fix off-centre read ~0.80) Annotated overlay + raw metrics still attached to the report every run. Runs via `npm run test:geometry` (not in the PR CI gate — needs python+cv2). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 55 ++++++++- packages/web/src/pages/Workspace.tsx | 14 ++- tests/diagnostics/graph-balance.spec.ts | 26 ++-- tests/e2e/camera.spec.ts | 113 ++++++++++++++++++ 4 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/camera.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 01916271..af8048c5 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -197,6 +197,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // descendInto from context isn't memoized; hold the latest in a ref so the // D3-bound node click handler can call it without re-binding every render. const descendIntoRef = useRef(descendInto); + // Camera persistence: remember the user's view per-graph + auto-fit once. + const currentGraphIdRef = useRef(currentGraph?.id); + currentGraphIdRef.current = currentGraph?.id; + const fittedGraphsRef = useRef>(new Set()); + const cameraSaveTimerRef = useRef | null>(null); // Mirrors isSimplified for the d3 tick closure (which captures stale render // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. const simplifiedRef = useRef(false); @@ -3833,6 +3838,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i y: event.transform.y, scale: event.transform.k }); + // Persist the user's camera per-graph (debounced) so their view survives a + // reload and isn't reset on the next visit. localStorage only — no D1 chatter. + { + const gid = currentGraphIdRef.current; + if (gid) { + const cam = { x: event.transform.x, y: event.transform.y, k: event.transform.k }; + if (cameraSaveTimerRef.current) clearTimeout(cameraSaveTimerRef.current); + cameraSaveTimerRef.current = setTimeout(() => { + try { localStorage.setItem(`graphdone:camera:${gid}`, JSON.stringify(cam)); } catch { /* ignore */ } + }, 600); + } + } // Re-cull on pan/zoom. The one-shot sim is usually stopped during pan, so // this is the only thing that reveals nodes panned back into view (and @@ -3995,6 +4012,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i svg.call(d3.zoom().transform as any, transform); } }, [getNodeDimensions]); + // Latest fit fn in a ref so the once-per-graph camera effect can call it WITHOUT + // listing it as a dep — its identity churns during settle, which would otherwise + // cancel the fit timer before it fires (the bug that left graphs off-screen). + const fitViewRef = useRef(fitViewToNodes); + fitViewRef.current = fitViewToNodes; // Mini-map click → pan the main view to that graph point at current zoom useEffect(() => { @@ -4243,11 +4265,28 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return () => cancelAnimationFrame(raf); }, [inlineEditNodeId]); const currentGraphId = currentGraph?.id; + // On graph load: restore the user's saved camera for THIS graph (respect their + // in-session view) or auto-fit ONCE so it never loads off-screen, then jumps in. + // Keyed on the graph id; never re-fits a graph already framed (don't fight the + // user). Uses fitViewRef (not fitViewToNodes) so the timer isn't cancelled by + // the callback's identity churning during physics settle. useEffect(() => { - if (!hasNodes || !svgRef.current) return undefined; - const timer = setTimeout(() => fitViewToNodes(), 1500); + if (!hasNodes || !svgRef.current || !currentGraphId) return undefined; + if (fittedGraphsRef.current.has(currentGraphId)) return undefined; + const gid = currentGraphId; + const timer = setTimeout(() => { + if (fittedGraphsRef.current.has(gid)) return; + fittedGraphsRef.current.add(gid); + let saved: { x: number; y: number; k: number } | null = null; + try { const r = localStorage.getItem(`graphdone:camera:${gid}`); saved = r ? JSON.parse(r) : null; } catch { /* ignore */ } + if (saved && typeof saved.k === 'number' && zoomBehaviorRef.current && svgRef.current) { + d3.select(svgRef.current).call(zoomBehaviorRef.current.transform as any, d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k)); + } else { + fitViewRef.current(); + } + }, 1200); return () => clearTimeout(timer); - }, [hasNodes, currentGraphId, fitViewToNodes]); + }, [hasNodes, currentGraphId]); // PR-3: keep the expand-in-place panel glued to its node through drags, ticks // and pan/zoom (same rAF technique as the rename box). The panel sits beside @@ -4309,6 +4348,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, [resetLayout, onResetLayout]); + // Expose "zoom to fit" (zoom extents) so the Workspace toolbar button can frame + // every node without touching the camera-restore state. Calls through the ref so + // the latest fit fn is used regardless of when the button mounts. + useEffect(() => { + (window as any).triggerZoomToFit = () => fitViewRef.current(); + return () => { + delete (window as any).triggerZoomToFit; + }; + }, []); + // Force reinitialization trigger - incremented when view needs refresh const [reinitTrigger, setReinitTrigger] = useState(0); diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 6c5d6a83..61f4792a 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Maximize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -438,6 +438,18 @@ export function Workspace() { {graphLocked ? : } {graphLocked ? 'Locked' : 'Editing'} + {/* Zoom-extents: frame every node, independent of the camera-restore + state. Sits under the lock toggle on phones; top-left on desktop, + where the right corner is taken by the docked inspector. */} + {inspectorNode && (
setInspectorNode(null)} /> diff --git a/tests/diagnostics/graph-balance.spec.ts b/tests/diagnostics/graph-balance.spec.ts index 2d549878..fb0c65d3 100644 --- a/tests/diagnostics/graph-balance.spec.ts +++ b/tests/diagnostics/graph-balance.spec.ts @@ -13,12 +13,20 @@ import * as path from 'node:path'; * graph is placed: centroid offset from centre, bbox coverage, content usage, * margin balance, quadrant mass distribution, and an informational balanceScore. * - * PHASE 1 = measurement, not a verdict. It records numbers + an annotated - * overlay into the report and only asserts that content was detected — so we get - * objective baselines first. Centering/usage THRESHOLDS (pass/fail) come once the - * camera-centering work lands and we know what "good" looks like numerically. + * PHASE 2 = a gate. The camera-centering work landed (graph loads framed + + * once-per-graph fit), so we now know what "good" looks like numerically: + * offMag ~0.05 and bbox coverage ~0.93 across desktop/laptop/tablet. The + * thresholds below have a wide margin over those values and would have FAILED + * the pre-fix state (offMag ~0.80, ~6% coverage — graph off in a corner), so a + * regression that pushes the graph off-screen again is caught. The annotated + * overlay + raw metrics are still attached to the report for every run. */ +// Pass/fail thresholds (see header). Wide margins over the measured good state +// so they catch a real regression (off-screen / cornered graph) without flaking. +const MAX_OFF_MAG = 0.30; // centroid offset from frame centre (0 = dead centre) +const MIN_BBOX_COVERAGE = 0.35; // graph bbox vs canvas (off-screen graph ~= 0.06) + const PY = path.join(process.cwd(), 'tests/helpers/balance_metrics.py'); const OUT = path.join(process.cwd(), 'test-artifacts/balance'); mkdirSync(OUT, { recursive: true }); @@ -65,13 +73,11 @@ test.describe('graph balance metrics (OpenCV) @balance', () => { await info.attach(`balance-${r.name}`, { path: ann, contentType: 'image/jpeg' }); await info.attach(`metrics-${r.name}`, { body: JSON.stringify(m, null, 2), contentType: 'application/json' }); - // Phase 1 is measurement, not a gate: record even a near-empty canvas - // (itself a signal the graph rendered off-screen) instead of failing. - // Centering/usage THRESHOLDS become pass/fail once the camera work lands. + // Gate: the graph must actually be on-screen, framed, and centred. expect(m, 'metrics computed').toBeTruthy(); - if ((m.contentPixels ?? 0) < 200) { - console.warn(`[balance ${r.name}] near-empty canvas (${m.contentPixels}px) — graph likely rendered off-screen`); - } + expect(m.contentPixels ?? 0, `content detected on canvas (${m.contentPixels}px) — a near-empty canvas means the graph rendered off-screen`).toBeGreaterThan(200); + expect(b.coverage ?? 0, `graph bbox fills the canvas (coverage ${b.coverage}); a cornered/off-screen graph reads ~0.06`).toBeGreaterThan(MIN_BBOX_COVERAGE); + expect(c.offMag ?? 1, `graph is centred (offMag ${c.offMag}, dx=${c.offX} dy=${c.offY}); off-centre pre-fix read ~0.80`).toBeLessThan(MAX_OFF_MAG); }); } }); diff --git a/tests/e2e/camera.spec.ts b/tests/e2e/camera.spec.ts new file mode 100644 index 00000000..14782da5 --- /dev/null +++ b/tests/e2e/camera.spec.ts @@ -0,0 +1,113 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../helpers/auth'; + +/** + * Camera framing + persistence (@camera). + * + * Covers two user-facing promises from the camera work: + * 1. The graph loads framed (never off-screen, then jumps in) and a "zoom to + * fit" (zoom-extents) button re-frames every node on demand. + * 2. The user's camera is respected within/across a session — panning is saved + * to localStorage per-graph and restored on reload, instead of being reset. + */ + +async function gotoGraph(page: Page) { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await login(page, TEST_USERS.ADMIN); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }); + await page.waitForTimeout(3500); // physics settle + the once-per-graph framing +} + +// How many nodes have any part inside the canvas viewport. +async function nodesInView(page: Page): Promise<{ inView: number; total: number }> { + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) return { inView: 0, total: 0 }; + const boxes = await page.locator('.graph-container svg .node').evaluateAll((els) => + els.map((el) => (el as SVGGraphicsElement).getBoundingClientRect()).map((r) => ({ x: r.x, y: r.y, w: r.width, h: r.height })) + ); + const inView = boxes.filter( + (b) => b.x + b.w > canvas.x && b.x < canvas.x + canvas.width && b.y + b.h > canvas.y && b.y < canvas.y + canvas.height + ).length; + return { inView, total: boxes.length }; +} + +test.describe('camera framing + persistence @camera', () => { + test.describe.configure({ timeout: 90_000 }); + + test('graph loads framed (nodes on-screen, not off in a corner)', async ({ page }) => { + await gotoGraph(page); + const { inView, total } = await nodesInView(page); + expect(total, 'graph has nodes').toBeGreaterThan(0); + // The framing fit centres the bbox; the bulk of nodes must be on-screen. + expect(inView, `${inView}/${total} nodes in view on load`).toBeGreaterThan(total * 0.5); + }); + + test('zoom-extents button re-frames nodes after a hard zoom-in', async ({ page }) => { + await gotoGraph(page); + const btn = page.locator('[data-testid="graph-zoom-extents"]'); + await expect(btn, 'zoom-extents button is present on graph view').toBeVisible(); + + // Zoom IN hard over the canvas centre (wheel, not drag) so peripheral nodes + // leave the viewport. Wheel avoids the minimap (bottom-right) and never grabs + // a node the way a drag-pan can. Custom wheelDelta: +deltaY == zoom in. + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) throw new Error('no canvas'); + const cx = canvas.x + canvas.width / 2; + const cy = canvas.y + canvas.height / 2; + await page.mouse.move(cx, cy); + for (let i = 0; i < 7; i++) { + await page.mouse.wheel(0, 320); + await page.waitForTimeout(90); + } + await page.waitForTimeout(500); + const zoomed = await nodesInView(page); + expect(zoomed.total, 'graph has nodes').toBeGreaterThan(0); + expect(zoomed.inView, `zoom-in pushed some nodes off-screen (${zoomed.inView}/${zoomed.total})`).toBeLessThan(zoomed.total); + + // Zoom-extents brings them all back into frame. + await btn.click(); + await page.waitForTimeout(900); // the 450ms fit transition + margin + const reframed = await nodesInView(page); + expect( + reframed.inView, + `zoom-extents reframed ${reframed.inView}/${reframed.total}` + ).toBeGreaterThanOrEqual(Math.ceil(reframed.total * 0.8)); + }); + + test('camera pan is saved per-graph and restored on reload', async ({ page }) => { + await gotoGraph(page); + const gid = await page.evaluate(() => { + const keys = Object.keys(localStorage); + return keys.find((k) => k.startsWith('graphdone:camera:'))?.replace('graphdone:camera:', '') ?? null; + }); + + // Pan, then wait past the 600ms debounce so it persists. + const canvas = await page.locator('.graph-container').first().boundingBox(); + if (!canvas) throw new Error('no canvas'); + await page.mouse.move(canvas.x + canvas.width / 2, canvas.y + canvas.height / 2); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width / 2 - 220, canvas.y + canvas.height / 2 - 160, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(900); + + const saved = await page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => k.startsWith('graphdone:camera:')); + return key ? localStorage.getItem(key) : null; + }); + expect(saved, 'camera persisted to localStorage after pan').toBeTruthy(); + const cam = JSON.parse(saved!); + expect(typeof cam.x === 'number' && typeof cam.y === 'number' && typeof cam.k === 'number').toBeTruthy(); + + // Reload: the saved camera should be restored, not reset to a fresh fit. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }); + await page.waitForTimeout(3000); + const after = await page.evaluate((k) => localStorage.getItem(k), `graphdone:camera:${gid}`); + expect(after, 'saved camera survives reload').toBeTruthy(); + const restored = JSON.parse(after!); + // Restore applies the saved transform; allow drift only from later auto-saves + // of the same view (no fresh fit, which would change k substantially). + expect(Math.abs(restored.k - cam.k), 'zoom level preserved across reload').toBeLessThan(0.05); + }); +}); From 1b79ae161701ba42baa45c265ba857ba1cff83e9 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 16:19:33 -0700 Subject: [PATCH 027/236] =?UTF-8?q?BRAND-1:=20drop=20"project=20management?= =?UTF-8?q?"=20=E2=86=92=20"management"=20in=20non-SPA=20surfaces=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA copy was already rebranded (index.html "Management, Reimagined"; package.json "Management for teams who think differently"). Finish the job on the developer-facing surfaces so the wording is consistent everywhere: - start: CLI help line + ASCII banner ("Graph-native management system") - public/install.sh: installer description comment - tools/document.sh: generated overview ("reimagines management as...") - tests/run-all-tests.js: HTML report subtitle - CLAUDE.md: rename "Slick Dialog Revolution" heading → "Contextual Dialog Direction" (drop the "revolution" framing) Short, descriptive, general "management" — not "project management", not the "everything tool". Co-authored-by: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- public/install.sh | 2 +- start | 4 ++-- tests/run-all-tests.js | 2 +- tools/document.sh | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cfb3a702..7eee9137 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,7 +281,7 @@ const GraphView = () => { }; ``` -## Current UI Evolution: Slick Dialog Revolution +## Contextual Dialog Direction (slick inline editors) GraphDone is undergoing a **major UI transformation** moving away from heavy modal dialogs toward slick, contextual, inline-style editors. This shift prioritizes **immediate feedback, minimal context switching, and delightful micro-interactions**. diff --git a/public/install.sh b/public/install.sh index b9f7e40c..9fd1e8cc 100755 --- a/public/install.sh +++ b/public/install.sh @@ -10,7 +10,7 @@ # # 📖 DESCRIPTION # ============================================================================ -# Automated installer for GraphDone - a graph-native project management +# Automated installer for GraphDone - a graph-native management # system that reimagines work coordination through dependencies and # democratic prioritization. Handles complete setup from dependency # installation to running services with beautiful CLI progress feedback. diff --git a/start b/start index 435bf4ac..8e3aadb8 100755 --- a/start +++ b/start @@ -60,7 +60,7 @@ detect_platform # Help function show_help() { - echo -e "${BOLD}GraphDone${NC} - Graph-native project management system" + echo -e "${BOLD}GraphDone${NC} - Graph-native management system" echo "" echo -e "${BOLD}USAGE:${NC}" echo " ./start [COMMAND] [OPTIONS]" @@ -196,7 +196,7 @@ show_banner() { echo "║ ║" echo "║ 🌐 GraphDone 🌐 ║" echo "║ ║" - echo "║ Graph-native project management system ║" + echo "║ Graph-native management system ║" echo "║ ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" diff --git a/tests/run-all-tests.js b/tests/run-all-tests.js index 0b116eae..efd80d7e 100644 --- a/tests/run-all-tests.js +++ b/tests/run-all-tests.js @@ -735,7 +735,7 @@ function generateHTMLReport() {

GraphDone Test Report

-
Comprehensive testing results for graph-native project management
+
Comprehensive testing results for graph-native management
Generated: ${new Date().toLocaleString()} | Environment: ${testResults.environment} | diff --git a/tools/document.sh b/tools/document.sh index 31ee9f8c..32928187 100755 --- a/tools/document.sh +++ b/tools/document.sh @@ -133,7 +133,7 @@ if [ "$BUILD_DOCS" = true ]; then

Project Overview

-

GraphDone reimagines project management as a collaborative graph where work flows through natural dependencies rather than artificial hierarchies.

+

GraphDone reimagines management as a collaborative graph where work flows through natural dependencies rather than artificial hierarchies.

Key Features

    From 4f3f684471dcc307f113c1f89ad1baf8da32cccc Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 16:51:12 -0700 Subject: [PATCH 028/236] AUTH-2: keep-me-logged-in by default + clear copy + session-only opt-out (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strong client-side session caching so users don't log in over and over. - New lib/authStorage.ts: single accessor for the auth token + cached user. keep=true → localStorage (survives browser restart; the default); keep=false → sessionStorage (cleared when the tab closes). Reads prefer localStorage then fall back to sessionStorage; switching modes clears the stale copy; the keep flag (graphdone:keepLoggedIn) defaults to true. - Route apollo.ts (authLink, errorLink clears, wsLink) and AuthContext (mount/login/logout + ME refresh) through authStorage instead of poking localStorage directly. - AuthContext.login(user, token, keepLoggedIn?) threads the choice; defaults to the user's last preference. - Signin: "Keep me logged in" checkbox now DEFAULT ON with clear copy ("Stay signed in on this device — uncheck on shared computers"); magic-link sign-in keeps you logged in by default (you clicked your own link). - Unit test (8 cases) for the storage logic. THE GATE 5/5 (login unaffected). Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/contexts/AuthContext.tsx | 34 +++--- .../web/src/lib/__tests__/authStorage.test.ts | 80 +++++++++++++ packages/web/src/lib/apollo.ts | 11 +- packages/web/src/lib/authStorage.ts | 109 ++++++++++++++++++ packages/web/src/pages/Signin.tsx | 31 +++-- packages/web/src/types/auth.ts | 2 +- 6 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 packages/web/src/lib/__tests__/authStorage.test.ts create mode 100644 packages/web/src/lib/authStorage.ts diff --git a/packages/web/src/contexts/AuthContext.tsx b/packages/web/src/contexts/AuthContext.tsx index deed328c..8d568a29 100644 --- a/packages/web/src/contexts/AuthContext.tsx +++ b/packages/web/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { useLazyQuery, gql } from '@apollo/client'; import { User, Team, AuthContextType } from '../types/auth'; +import { getToken, getUserRaw, setSession, setUser, clearSession, isKept } from '../lib/authStorage'; const ME_QUERY = gql` query Me { @@ -42,10 +43,11 @@ export function AuthProvider({ children }: AuthProviderProps) { if (meData.me) { setCurrentUser(meData.me); setCurrentTeam(meData.me.team); + // Refresh the cached user so it stays in sync with the server. + setUser(meData.me); } else { // ME query returned null, clear stale data - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); setCurrentUser(null); setCurrentTeam(null); } @@ -56,25 +58,26 @@ export function AuthProvider({ children }: AuthProviderProps) { useEffect(() => { if (meError) { // Token is invalid, clear it - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); setCurrentUser(null); setCurrentTeam(null); setIsInitializing(false); } }, [meError]); - // Load saved user from localStorage and validate token on mount + // Load saved session (localStorage if "keep me logged in", else sessionStorage) + // and validate the token on mount. useEffect(() => { - const token = localStorage.getItem('authToken'); - const savedUser = localStorage.getItem('currentUser'); + const token = getToken(); + const savedUser = getUserRaw(); if (token) { if (savedUser) { try { JSON.parse(savedUser); } catch (error) { - localStorage.removeItem('currentUser'); + // Corrupt cache — drop it; the ME query will repopulate. + clearSession(); } } getMe(); @@ -83,21 +86,24 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, [getMe]); - const login = (user: User, token?: string) => { + // keepLoggedIn defaults to the user's last choice (true unless they opted out) + // so callers that don't pass it (e.g. guest login) honour the preference. + const login = (user: User, token?: string, keepLoggedIn: boolean = isKept()) => { setCurrentUser(user); setCurrentTeam(user.team || null); - + if (token) { - localStorage.setItem('authToken', token); + setSession(token, user, keepLoggedIn); + } else { + // No new token (e.g. a user refresh) — just update the cached user. + setUser(user); } - localStorage.setItem('currentUser', JSON.stringify(user)); }; const logout = () => { setCurrentUser(null); setCurrentTeam(null); - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); }; const switchUser = (_userId: string) => { diff --git a/packages/web/src/lib/__tests__/authStorage.test.ts b/packages/web/src/lib/__tests__/authStorage.test.ts new file mode 100644 index 00000000..c12d2ca2 --- /dev/null +++ b/packages/web/src/lib/__tests__/authStorage.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getToken, + getUserRaw, + isKept, + setSession, + setToken, + setUser, + clearSession, +} from '../authStorage'; + +const TOKEN = 'authToken'; +const USER = 'currentUser'; + +describe('authStorage', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('keep=true persists the session in localStorage (survives restart)', () => { + setSession('tok-1', { id: 'u1' }, true); + expect(localStorage.getItem(TOKEN)).toBe('tok-1'); + expect(sessionStorage.getItem(TOKEN)).toBeNull(); + expect(getToken()).toBe('tok-1'); + expect(JSON.parse(getUserRaw()!)).toEqual({ id: 'u1' }); + expect(isKept()).toBe(true); + }); + + it('keep=false stores the session only in sessionStorage', () => { + setSession('tok-2', { id: 'u2' }, false); + expect(sessionStorage.getItem(TOKEN)).toBe('tok-2'); + expect(localStorage.getItem(TOKEN)).toBeNull(); + expect(getToken()).toBe('tok-2'); + expect(isKept()).toBe(false); + }); + + it('switching keep clears the stale copy in the other store', () => { + setSession('tok-keep', { id: 'a' }, true); + setSession('tok-session', { id: 'a' }, false); + expect(localStorage.getItem(TOKEN)).toBeNull(); // old persistent copy gone + expect(sessionStorage.getItem(TOKEN)).toBe('tok-session'); + expect(getToken()).toBe('tok-session'); + }); + + it('defaults to "kept" when no preference was ever recorded', () => { + expect(isKept()).toBe(true); + }); + + it('getToken prefers localStorage over sessionStorage', () => { + sessionStorage.setItem(TOKEN, 'session-tok'); + localStorage.setItem(TOKEN, 'local-tok'); + expect(getToken()).toBe('local-tok'); + }); + + it('setToken stores just the token (user populated later) with the keep flag', () => { + setToken('magic-tok', true); + expect(localStorage.getItem(TOKEN)).toBe('magic-tok'); + expect(getUserRaw()).toBeNull(); + expect(isKept()).toBe(true); + }); + + it('setUser updates the cached user in whichever store holds the token', () => { + setSession('tok', { id: 'old' }, false); + setUser({ id: 'new' }); + expect(JSON.parse(sessionStorage.getItem(USER)!)).toEqual({ id: 'new' }); + expect(localStorage.getItem(USER)).toBeNull(); + }); + + it('clearSession removes token + user from BOTH stores but keeps the preference', () => { + setSession('tok', { id: 'a' }, false); + clearSession(); + expect(getToken()).toBeNull(); + expect(getUserRaw()).toBeNull(); + expect(localStorage.getItem(TOKEN)).toBeNull(); + expect(sessionStorage.getItem(TOKEN)).toBeNull(); + // The keep preference is intentionally retained for the next login. + expect(isKept()).toBe(false); + }); +}); diff --git a/packages/web/src/lib/apollo.ts b/packages/web/src/lib/apollo.ts index 17093cda..7ade50ac 100644 --- a/packages/web/src/lib/apollo.ts +++ b/packages/web/src/lib/apollo.ts @@ -4,6 +4,7 @@ import { onError } from '@apollo/client/link/error'; import { getMainDefinition } from '@apollo/client/utilities'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; +import { getToken, clearSession } from './authStorage'; // Dynamically construct GraphQL URLs based on current host const getGraphQLUrl = () => { @@ -28,7 +29,7 @@ const httpLink = createHttpLink({ }); const authLink = setContext((_, { headers }) => { - const token = localStorage.getItem('authToken'); + const token = getToken(); return { headers: { ...headers, @@ -44,8 +45,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) if (error.extensions?.code === 'UNAUTHENTICATED' || error.message.includes('Invalid token') || error.message.includes('Unauthorized')) { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); // Don't reload immediately - let auth context handle it return; } @@ -55,8 +55,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) if (networkError) { // Handle 401/403 network errors if ('statusCode' in networkError && (networkError.statusCode === 401 || networkError.statusCode === 403)) { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); // Don't reload immediately - let auth context handle it return; } @@ -67,7 +66,7 @@ const wsLink = new GraphQLWsLink( createClient({ url: getWebSocketUrl(), connectionParams: () => { - const token = localStorage.getItem('authToken'); + const token = getToken(); return { authorization: token ? `Bearer ${token}` : '', }; diff --git a/packages/web/src/lib/authStorage.ts b/packages/web/src/lib/authStorage.ts new file mode 100644 index 00000000..07b04aed --- /dev/null +++ b/packages/web/src/lib/authStorage.ts @@ -0,0 +1,109 @@ +/** + * Auth session storage with an explicit "keep me logged in" choice. + * + * - keep = true → persist the session in localStorage (survives browser restart; + * the default, so users don't have to log in over and over). + * - keep = false → store in sessionStorage (cleared when the tab/window closes). + * + * Reads prefer localStorage then fall back to sessionStorage, so a single + * accessor works regardless of which mode the session was created in. The token + * and user are always kept in the SAME store; the "keep" flag itself lives in + * localStorage so the Sign-in form can default the checkbox to the last choice. + */ + +const TOKEN_KEY = 'authToken'; +const USER_KEY = 'currentUser'; +const KEEP_KEY = 'graphdone:keepLoggedIn'; + +function safeGet(store: Storage, key: string): string | null { + try { + return store.getItem(key); + } catch { + return null; + } +} + +function safeRemove(store: Storage, key: string): void { + try { + store.removeItem(key); + } catch { + /* ignore (storage disabled / private mode) */ + } +} + +/** The auth token from whichever store holds the session (localStorage first). */ +export function getToken(): string | null { + return safeGet(localStorage, TOKEN_KEY) ?? safeGet(sessionStorage, TOKEN_KEY); +} + +/** The cached user JSON string from whichever store holds the session. */ +export function getUserRaw(): string | null { + return safeGet(localStorage, USER_KEY) ?? safeGet(sessionStorage, USER_KEY); +} + +/** Whether the user chose to stay logged in. Defaults to true (keep me in). */ +export function isKept(): boolean { + return safeGet(localStorage, KEEP_KEY) !== 'false'; +} + +/** + * Persist a session. `keep` decides the store; the other store is cleared so a + * stale copy never shadows the current one. + */ +export function setSession(token: string, user: unknown, keep: boolean): void { + const primary = keep ? localStorage : sessionStorage; + const secondary = keep ? sessionStorage : localStorage; + try { + primary.setItem(TOKEN_KEY, token); + primary.setItem(USER_KEY, typeof user === 'string' ? user : JSON.stringify(user)); + } catch { + /* ignore */ + } + safeRemove(secondary, TOKEN_KEY); + safeRemove(secondary, USER_KEY); + try { + localStorage.setItem(KEEP_KEY, keep ? 'true' : 'false'); + } catch { + /* ignore */ + } +} + +/** + * Persist just a token (the user is fetched separately, e.g. after a magic-link + * redirect). Sets the keep store + flag the same way as setSession. + */ +export function setToken(token: string, keep: boolean): void { + const primary = keep ? localStorage : sessionStorage; + const secondary = keep ? sessionStorage : localStorage; + try { + primary.setItem(TOKEN_KEY, token); + } catch { + /* ignore */ + } + safeRemove(secondary, TOKEN_KEY); + safeRemove(secondary, USER_KEY); + try { + localStorage.setItem(KEEP_KEY, keep ? 'true' : 'false'); + } catch { + /* ignore */ + } +} + +/** Update just the cached user (e.g. after a profile refresh), in-place. */ +export function setUser(user: unknown): void { + const raw = typeof user === 'string' ? user : JSON.stringify(user); + const store = safeGet(localStorage, TOKEN_KEY) != null ? localStorage : sessionStorage; + try { + store.setItem(USER_KEY, raw); + } catch { + /* ignore */ + } +} + +/** Clear the session from BOTH stores (leaves the keep-preference intact). */ +export function clearSession(): void { + for (const store of [localStorage, sessionStorage]) { + safeRemove(store, TOKEN_KEY); + safeRemove(store, USER_KEY); + } +} diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index 823bf719..2f48ccff 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { setToken } from '../lib/authStorage'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; @@ -97,7 +98,9 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea const [magicLinkSent, setMagicLinkSent] = useState(false); const [magicLinkLoading, setMagicLinkLoading] = useState(false); const [errors, setErrors] = useState>({}); - const [rememberMe, setRememberMe] = useState(false); + // Default ON — keep users signed in on this device so they don't have to log + // in over and over (clearly labelled below; uncheck for a session-only login). + const [rememberMe, setRememberMe] = useState(true); const [emailValid, setEmailValid] = useState(null); const [magicLinkEmailValid, setMagicLinkEmailValid] = useState(null); const [loginAttempts, setLoginAttempts] = useState(0); @@ -179,7 +182,10 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea setErrors({ submit: 'Authentication failed. Please try again.' }); } } else if (token) { - localStorage.setItem('authToken', token); + // Magic-link sign-in: keep them signed in on this device by default + // (they clicked their own link). The user is loaded by the ME query + // after reload. + setToken(token, true); window.history.replaceState({}, '', '/login'); window.location.reload(); } @@ -239,7 +245,7 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea setLoginAttempts(0); localStorage.removeItem('loginAttempts'); localStorage.removeItem('lockoutTime'); - setAuthUser(data.login.user, data.login.token); + setAuthUser(data.login.user, data.login.token, rememberMe); navigate('/'); }, onError: (error) => { @@ -804,10 +810,10 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea {errors.password && }
- {/* Remember Me & Forgot Password */} -
-