Skip to content
Draft
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
24 changes: 24 additions & 0 deletions apps/sim/app/(auth)/components/auth-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** @vitest-environment node */
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { expect, it, vi } from 'vitest'
import { AuthShell } from '@/app/(auth)/components/auth-shell'

vi.mock('next/link', () => ({
default: ({ children }: { children: ReactNode }) => (
<a href='/' data-client-navigation>
{children}
</a>
),
}))
vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null }))
vi.mock('@/app/(landing)/components/navbar/components', () => ({
LogoMark: ({ children }: { children: ReactNode }) => <>{children}</>,
SimWordmark: () => 'Sim',
}))

it('returns home through a document link so route-specific theme defaults reinitialize', () => {
const html = renderToStaticMarkup(<AuthShell>Sign in</AuthShell>)
expect(html).toContain('href="/" aria-label="Sim home"')
expect(html).not.toContain('data-client-navigation')
})
7 changes: 4 additions & 3 deletions apps/sim/app/(auth)/components/auth-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import Link from 'next/link'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'

Expand All @@ -19,6 +18,8 @@ interface AuthShellProps {
* the canvas/`--text-primary` surface, and renders a logo-only header that reuses
* the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The
* single content column is centered and capped for a calm single-form layout.
* The home link starts a document navigation so the marketing theme default is
* initialized independently of auth's forced-light context.
*
* The shell also owns the macOS traffic-light lane, unconditionally — every surface that
* wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside
Expand All @@ -34,11 +35,11 @@ export function AuthShell({ children, footer }: AuthShellProps) {
<DesktopTitleBarLane />
<header>
<nav className='mx-auto flex w-full max-w-[1446px] items-center px-12 py-4 max-sm:px-5 max-lg:px-8'>
<Link href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
<a href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
<LogoMark>
<SimWordmark />
</LogoMark>
</Link>
</a>
</nav>
</header>
<div className='flex flex-1 items-center justify-center px-4 pb-16'>
Expand Down
132 changes: 132 additions & 0 deletions apps/sim/app/(auth)/signup/signup-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/** @vitest-environment jsdom */
import { act, type InputHTMLAttributes, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SignupForm from '@/app/(auth)/signup/signup-form'

const { push, signUp, refetchSession } = vi.hoisted(() => ({
push: vi.fn(),
signUp: vi.fn(),
refetchSession: vi.fn(),
}))

vi.mock('next/navigation', () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@marsidev/react-turnstile', () => ({ Turnstile: () => null }))
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: vi.fn() }))
vi.mock('@/lib/auth/auth-client', () => ({
client: { signUp: { email: signUp } },
useSession: () => ({ refetch: refetchSession }),
}))
vi.mock('@/lib/consent/tracking-consent', () => ({
useTrackingConsent: () => ({ measurement: false }),
}))
vi.mock('@/lib/core/config/env', () => ({ getEnv: () => undefined, isFalsy: () => false }))
vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: false }))
vi.mock('@/lib/core/security/input-validation', () => ({ validateCallbackUrl: () => false }))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: () => ({ isValid: true }),
}))
vi.mock('@/lib/posthog/client', () => ({ captureClientEvent: vi.fn(), captureEvent: vi.fn() }))
vi.mock('@/app/(auth)/components', () => ({
AuthDivider: () => null,
AuthField: ({ children }: { children: ReactNode }) => <>{children}</>,
AuthFormMessage: () => null,
AuthHeader: () => null,
AuthInput: ({ error, ...props }: InputHTMLAttributes<HTMLInputElement> & { error?: boolean }) => (
<input {...props} />
),
AuthLegalFooter: () => null,
AuthNavPrompt: () => null,
AuthSubmitButton: ({ children }: { children: ReactNode }) => (
<button type='submit'>{children}</button>
),
PasswordInput: ({
error,
...props
}: InputHTMLAttributes<HTMLInputElement> & { error?: boolean }) => <input {...props} />,
SocialLoginButtons: () => null,
SSOLoginButton: () => null,
}))

let root: Root
let host: HTMLDivElement
let destination: { href: string }

beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
destination = { href: '' }
const browser = window
vi.stubGlobal(
'window',
new Proxy(browser, {
get(target, key) {
return key === 'location' ? destination : Reflect.get(target, key, target)
},
})
)
signUp.mockResolvedValue({ data: { user: { id: 'new-user' } } })
refetchSession.mockResolvedValue(undefined)
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
vi.unstubAllGlobals()
})

async function submit(emailVerificationEnabled: boolean) {
act(() =>
root.render(
<SignupForm
githubAvailable={false}
googleAvailable={false}
microsoftAvailable={false}
emailSignupEnabled
emailVerificationEnabled={emailVerificationEnabled}
/>
)
)
const fields = { name: 'Test Builder', email: 'builder@example.com', password: 'SafePass1!' }
for (const [name, value] of Object.entries(fields)) {
const input = host.querySelector<HTMLInputElement>(`input[name="${name}"]`)
if (!input) throw new Error(`Missing ${name} input`)
input.value = value
}
const form = host.querySelector('form')
if (!form) throw new Error('Missing signup form')
await act(async () =>
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
)
}

describe('signup shell navigation', () => {
it('starts a document navigation after a successful signup without verification', async () => {
await submit(false)
expect(signUp).toHaveBeenCalledOnce()
expect(refetchSession).toHaveBeenCalledOnce()
expect(destination.href).toBe('/workspace')
expect(push).not.toHaveBeenCalled()
})

it('keeps verification within the auth shell and stores the email for the next step', async () => {
await submit(true)
expect(push).toHaveBeenCalledWith('/verify?fromSignup=true')
expect(sessionStorage.getItem('verificationEmail')).toBe('builder@example.com')
expect(destination.href).toBe('')
})

it('does not navigate when signup fails', async () => {
signUp.mockResolvedValue({ error: { message: 'Signup failed' } })
await submit(false)
expect(push).not.toHaveBeenCalled()
expect(destination.href).toBe('')
})
})
8 changes: 3 additions & 5 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,10 @@ function SignupFormContent({

if (destination.kind === 'verify') {
router.push(VERIFY_FROM_SIGNUP_ROUTE)
} else if (destination.kind === 'redirect') {
// Full navigation, matching the verify hop: the destination (invite, CLI
// handoff) is server-rendered and must see the fresh session cookie.
window.location.href = destination.url
} else {
router.push(DEFAULT_POST_AUTH_ROUTE)
/** Match login/verification: refresh session-bound shells and their theme default. */
window.location.href =
destination.kind === 'redirect' ? destination.url : DEFAULT_POST_AUTH_ROUTE
}
} catch (error) {
logger.error('Signup error:', error)
Expand Down
10 changes: 5 additions & 5 deletions apps/sim/app/(landing)/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ This route group owns `/` and the entire public marketing surface - the home pag

## What this is

- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `<main>` content.
- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, painted pre-footer CTA, footer, site-wide JSON-LD); each page supplies only its `<main>` content. The painted light/dark CTA and footer are owned by `LandingShell`; never add page-specific closing CTA bands or footer instances.
- The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme.

## Styling - draw from the platform's light mode
## Styling - draw from the platform's tokens

The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme.
The landing page looks like the product. Its visual language is the workspace UI - light by default, dark on request - not a separate marketing theme.

- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme.
- **Light by default, dark on request.** The landing family follows the theme class on `<html>` (next-themes, storage key `sim-theme`): a first-time visitor gets light, the design baseline, and the footer's `ThemeToggle` switches to dark - the platform's own `.dark` token values from `app/_styles/globals.css`, no separate palette. Tokens flip on their own, so `dark:` variants exist here only to pair the handful of deliberate literals (the `#F8F8F8` paper band, the composer send button, the pale CTA drawing) with their dark value in the same class string - never leave a literal unpaired. Never read the theme in a Server Component; the toggle is the one client reader.
- **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing.
- **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`.
- **Typography is the platform's.** Season is the global body font (`font-season` is applied on `<body>` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see the `@theme` block in `app/_styles/globals.css`). Don't add new fonts or font CSS variables without explicit direction.
Expand Down Expand Up @@ -99,7 +99,7 @@ Absolute imports only in component code (`@/app/(landing)/components/...`); `ind

1. Server Component unless it provably needs client state; if client, it's a leaf.
2. H2 with `id` + `aria-labelledby` wiring; heading hierarchy intact.
3. Platform light tokens and emcn chrome only - no hex colors, no `--landing-*`, no `dark:`.
3. Platform tokens and emcn chrome only - no hex colors, no `--landing-*`. Check the section in dark mode too (footer toggle); any deliberate literal carries its `dark:` pair.
4. Images: `next/image`, explicit dimensions, `priority` only on the LCP element.
5. Copy passes the constitution (language table, claim hierarchy, tone).
6. "Sim" named explicitly; section quotable in isolation.
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/(landing)/careers/careers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ interface CareersProps {
* crawlable HTML; the interactive {@link JobBoard} hydrates on top to add
* Team/Location filtering.
*
* Both sections share the landing gutter — capped and centered at `max-w-[1460px]`
* with the navbar-aligned `px-20 max-lg:px-8 max-sm:px-5` so the headline starts on
* Both sections share the landing gutter — capped and centered at `max-w-[1728px]`
* with the navbar-aligned `px-10 max-md:px-7 max-lg:px-8 max-xl:px-9` so the headline starts on
* the same vertical line as the wordmark. The hero carries the single `<h1>`
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
Expand All @@ -45,7 +45,7 @@ export default async function Careers({ searchParams }: CareersProps) {
<section
id='careers-hero'
aria-labelledby='careers-heading'
className='mx-auto flex w-full max-w-[1460px] flex-col gap-5 px-20 pt-20 pb-10 max-sm:px-5 max-sm:pt-16 max-lg:px-8'
className='mx-auto flex w-full max-w-[1728px] flex-col gap-5 px-10 pt-20 pb-10 max-sm:pt-16 max-md:px-7 max-lg:px-8 max-xl:px-9'
>
<p className='sr-only'>
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
Expand All @@ -70,7 +70,7 @@ export default async function Careers({ searchParams }: CareersProps) {
<section
id='open-roles'
aria-labelledby='open-roles-heading'
className='mx-auto flex w-full max-w-[1460px] flex-col gap-10 px-20 pt-6 pb-24 max-sm:px-5 max-sm:pb-16 max-lg:px-8'
className='mx-auto flex w-full max-w-[1728px] flex-col gap-10 px-10 pt-6 pb-24 max-sm:pb-16 max-md:px-7 max-lg:px-8 max-xl:px-9'
>
<h2
id='open-roles-heading'
Expand Down
5 changes: 0 additions & 5 deletions apps/sim/app/(landing)/comparisons/[provider]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
SIM_LATEST_VERIFIED,
} from '@/app/(landing)/comparisons/utils'
import { BackLink } from '@/app/(landing)/components'
import { Cta } from '@/app/(landing)/components/cta/cta'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'

Expand Down Expand Up @@ -317,10 +316,6 @@ export default async function ComparisonProviderPage({

<div className='-mt-px h-px w-full bg-[var(--border)]' />
</main>

<div className='py-16'>
<Cta />
</div>
</>
)
}
Loading
Loading