Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions apps/desktop/e2e/authgate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,35 @@ import { launchApp } from './fixtures.js';

test.describe('AuthGate is the first window', () => {
test('unsigned launch shows magic-link Sign in, not Welcome or workspace', async () => {
const { window, cleanup } = await launchApp({ skipAuthGate: false });
const { app, window: page, cleanup } = await launchApp({ skipAuthGate: false });
try {
await expect(window.getByRole('tab', { name: 'Sign in' })).toBeVisible({
await expect(page.getByRole('tab', { name: 'Sign in' })).toBeVisible({
timeout: 15_000,
});
await expect(window.getByRole('heading', { name: 'Welcome back' })).toBeVisible();
await expect(window.getByText('The hackable AI note taker')).toBeVisible();
await expect(window.getByRole('button', { name: 'Email me a link' })).toBeVisible();
await expect(window.locator('canvas')).toHaveCount(1);
await expect(page.getByRole('heading', { name: 'Welcome back' })).toBeVisible();
await expect(page.getByText('The hackable AI note taker')).toBeVisible();
await expect(page.getByRole('button', { name: 'Email me a link' })).toBeVisible();
await expect(page.locator('canvas')).toHaveCount(1);

await expect(window.getByRole('button', { name: 'Create Your First Note' })).toHaveCount(0);
await expect(window.getByRole('button', { name: /continue locally/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Create Your First Note' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /continue locally/i })).toHaveCount(0);

const [settings] = await Promise.all([
app.waitForEvent('window'),
page.evaluate(() =>
(
globalThis as unknown as {
dripnex: { windows: { openSettings: () => Promise<{ ok: boolean }> } };
}
).dripnex.windows.openSettings()
),
]);
await settings.waitForLoadState('domcontentloaded');
await settings.getByRole('button', { name: 'Account' }).click();
await expect(settings.getByRole('heading', { name: 'Account' })).toBeVisible();
await settings.getByRole('button', { name: 'Sign In' }).click();
await expect(settings.getByLabel('Email')).toBeVisible();
await expect(settings.getByRole('button', { name: 'Send Magic Link' })).toBeVisible();
} finally {
await cleanup();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.screen {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
justify-content: center;
Expand All @@ -8,7 +9,7 @@
background: var(--bg-base);
overflow: hidden;
font-family: var(--font-sans);
-webkit-app-region: drag;
-webkit-app-region: no-drag;
}

.card {
Expand All @@ -23,7 +24,8 @@
padding: var(--space-6) var(--space-6) var(--space-8);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--glass-bg);
/* Fallback first: backdrop-filter on Linux can composite the card away. */
background: var(--glass-bg-fallback);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
box-shadow: var(--shadow-lg);
}
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/renderer/components/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useAuthStore, selectError } from '../../stores/authStore';
import logo from '../../assets/logo.png';
import { LoginBackdrop } from './LoginBackdrop';
import { AUTH_GATE_FORM_Z_INDEX } from './authGateStacking';
import styles from './AuthGate.module.css';

/**
Expand Down Expand Up @@ -39,9 +40,9 @@ export function AuthGate({ hydrating = false }: { hydrating?: boolean }) {
);

return (
<div className={styles.screen}>
<div className={styles.screen} data-auth-gate="screen">
<LoginBackdrop />
<div className={styles.card}>
<div className={styles.card} style={{ zIndex: AUTH_GATE_FORM_Z_INDEX }} data-auth-gate="form">
<img src={logo} alt="" width={40} height={40} className={styles.logo} />
<p className={styles.kicker}>The hackable AI note taker</p>
<div className={styles.tabs} role="tablist">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
.layer {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}

.canvas {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: transparent;
}

.veil {
Expand Down
53 changes: 9 additions & 44 deletions apps/desktop/src/renderer/components/auth/LoginBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react';
import { hexToRgba } from './hexToRgba';
import { LOGIN_BACKDROP_Z_INDEX } from './authGateStacking';
import { paintTunnel } from './paintTunnel';
import styles from './LoginBackdrop.module.css';

const FRAME_COUNT = 16;
const CYCLE_MS = 16000;

function readToken(name: string, fallback: string): string {
Expand All @@ -16,45 +16,6 @@ function prefersReducedMotion(): boolean {
);
}

function paintTunnel(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
phase: number,
accent: string,
paper: string
): void {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = paper;
ctx.fillRect(0, 0, width, height);

const cx = width / 2;
const cy = height / 2;
const maxDim = Math.max(width, height);

for (let i = 0; i < FRAME_COUNT; i++) {
const t = (i / FRAME_COUNT + phase) % 1;
const z = 0.06 + t * 0.94;
const halfW = maxDim * 0.42 * z;
const halfH = maxDim * 0.26 * z;
const alpha = 0.05 + t * 0.22;
ctx.strokeStyle = hexToRgba(accent, alpha);
ctx.lineWidth = 0.8 + t * 1.4;
const x = cx - halfW;
const y = cy - halfH;
const w = halfW * 2;
const h = halfH * 2;
const r = 10 + t * 8;
ctx.beginPath();
if (typeof ctx.roundRect === 'function') {
ctx.roundRect(x, y, w, h, r);
} else {
ctx.rect(x, y, w, h);
}
ctx.stroke();
}
}

/**
* Perspective corridor of paper-and-ink frames behind AuthGate.
* Slow forward motion. Frozen to one frame when the user prefers reduced motion.
Expand Down Expand Up @@ -88,8 +49,7 @@ export function LoginBackdrop() {
canvas.clientWidth,
canvas.clientHeight,
phase,
readToken('--accent', '#5eead4'),
readToken('--bg-base', '#0a0b0d')
readToken('--accent', '#5eead4')
);
};

Expand Down Expand Up @@ -120,7 +80,12 @@ export function LoginBackdrop() {
}, []);

return (
<div aria-hidden="true" className={styles.layer}>
<div
aria-hidden="true"
className={styles.layer}
style={{ zIndex: LOGIN_BACKDROP_Z_INDEX }}
data-auth-gate="backdrop"
>
<canvas ref={canvasRef} className={styles.canvas} />
<div className={styles.veil} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
isolation: isolate;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}

Expand All @@ -23,12 +23,15 @@
}

.dialog {
background: var(--bg-primary);
background: var(--glass-bg-fallback);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 90%;
max-width: 450px;
position: relative;
z-index: 1;
isolation: isolate;
animation: slideUp 0.3s ease-out;
}

Expand Down Expand Up @@ -130,6 +133,13 @@
gap: var(--space-4);
}

.label {
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--text-muted);
text-align: left;
}

.input {
width: 100%;
padding: var(--space-3) var(--space-4);
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { useState, useCallback, useEffect, useRef, FormEvent } from 'react';
import { createPortal } from 'react-dom';
import { Mail, CheckCircle, AlertCircle, X, RefreshCw } from 'lucide';
import { Icon } from '../../ui/icons/Icon';
import { useAuthStore, selectIsAuthenticated, selectError } from '../../stores/authStore';
Expand Down Expand Up @@ -119,8 +120,8 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
}
}, [email, requestMagicLink, resendCooldown, isResending, startCooldown]);

return (
<div className={styles.overlay} onClick={handleCancel}>
return createPortal(
<div className={styles.overlay} onClick={handleCancel} data-auth-gate="magic-link">
<div className={styles.dialog} onClick={e => e.stopPropagation()}>
<button type="button" className={styles.closeButton} onClick={handleCancel}>
<Icon icon={X} size={20} />
Expand All @@ -139,7 +140,11 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
</div>

<form onSubmit={handleSubmitEmail} className={styles.form}>
<label className={styles.label} htmlFor="settings-auth-email">
Email
</label>
<input
id="settings-auth-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
Expand Down Expand Up @@ -244,6 +249,7 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
)}
</div>
</div>
</div>
</div>,
document.body
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { AUTH_GATE_FORM_Z_INDEX } from '../authGateStacking';

const here = dirname(fileURLToPath(import.meta.url));
const authGate = readFileSync(join(here, '../AuthGate.tsx'), 'utf8');
const screenCss = readFileSync(join(here, '../AuthGate.module.css'), 'utf8');

describe('AuthGate form is in the tree when unauthenticated', () => {
it('mounts the email field and magic-link action on the first window', () => {
expect(authGate).toContain('id="auth-email"');
expect(authGate).toContain('type="email"');
expect(authGate).toContain('Email me a link');
expect(authGate).toContain('<LoginBackdrop');
expect(authGate).toContain('data-auth-gate="form"');
expect(authGate).toContain('zIndex: AUTH_GATE_FORM_Z_INDEX');
expect(AUTH_GATE_FORM_Z_INDEX).toBeGreaterThan(0);
});

it('has no continue-locally or guest skip', () => {
expect(authGate).not.toContain('continueLocally');
expect(authGate).not.toMatch(/onClick=\{[^}]*continueLocally/);
expect(authGate).not.toMatch(/button[^>]*>[\s\S]*Continue locally/i);
expect(authGate).not.toMatch(/Continue locally/);
});

it('isolates stacking so the canvas cannot cover the card', () => {
expect(screenCss).toMatch(/\.screen\s*\{[^}]*isolation:\s*isolate/s);
expect(screenCss).toMatch(/\.card\s*\{[^}]*z-index:\s*1/s);
expect(screenCss).toContain('background: var(--glass-bg-fallback)');
expect(screenCss).not.toMatch(/-webkit-app-region:\s*drag/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
AUTH_GATE_FORM_Z_INDEX,
LOGIN_BACKDROP_Z_INDEX,
formStacksAboveBackdrop,
} from '../authGateStacking';

describe('authGateStacking', () => {
it('keeps the magic-link form above the LoginBackdrop canvas', () => {
expect(AUTH_GATE_FORM_Z_INDEX).toBeGreaterThan(LOGIN_BACKDROP_Z_INDEX);
expect(formStacksAboveBackdrop()).toBe(true);
expect(formStacksAboveBackdrop(1, 0)).toBe(true);
expect(formStacksAboveBackdrop(0, 0)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { LOGIN_BACKDROP_Z_INDEX } from '../authGateStacking';
import { paintTunnel } from '../paintTunnel';

const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(join(here, '../LoginBackdrop.module.css'), 'utf8');
const src = readFileSync(join(here, '../LoginBackdrop.tsx'), 'utf8');

function recordingContext() {
const calls: string[] = [];
const ctx = {
calls,
clearRect: (...args: number[]) => {
calls.push(`clearRect:${args.join(',')}`);
},
fillRect: (...args: number[]) => {
calls.push(`fillRect:${args.join(',')}`);
},
beginPath: () => {
calls.push('beginPath');
},
stroke: () => {
calls.push('stroke');
},
rect: () => {
calls.push('rect');
},
roundRect: () => {
calls.push('roundRect');
},
set fillStyle(value: string) {
calls.push(`fillStyle:${value}`);
},
set strokeStyle(value: string) {
calls.push(`strokeStyle:${value}`);
},
set lineWidth(value: number) {
calls.push(`lineWidth:${value}`);
},
};
return ctx;
}

describe('LoginBackdrop cannot paint over AuthGate', () => {
it('keeps the canvas layer at z-index 0 behind the form', () => {
expect(LOGIN_BACKDROP_Z_INDEX).toBe(0);
expect(css).toMatch(/\.layer\s*\{[^}]*z-index:\s*0/s);
expect(css).toContain('pointer-events: none');
expect(css).toContain('background: transparent');
expect(src).toContain('zIndex: LOGIN_BACKDROP_Z_INDEX');
expect(src).toContain('data-auth-gate="backdrop"');
});

it('does not fill the canvas with opaque paper', () => {
const ctx = recordingContext();
paintTunnel(ctx as unknown as CanvasRenderingContext2D, 800, 600, 0.38, '#5eead4');
expect(ctx.calls.some(call => call.startsWith('clearRect:'))).toBe(true);
expect(ctx.calls.some(call => call.startsWith('fillRect:'))).toBe(false);
expect(ctx.calls.some(call => call.startsWith('fillStyle:'))).toBe(false);
expect(ctx.calls.some(call => call === 'stroke')).toBe(true);
expect(src).not.toContain("readToken('--bg-base'");
expect(src).toContain("from './paintTunnel'");
});
});
Loading
Loading