diff --git a/apps/web/src/components/LandingManuscript.css b/apps/web/src/components/LandingManuscript.css new file mode 100644 index 0000000..fb49dd2 --- /dev/null +++ b/apps/web/src/components/LandingManuscript.css @@ -0,0 +1,126 @@ +.landing-manuscript { + min-width: 0; + width: 100%; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--paper); + box-shadow: 0 14px 36px color-mix(in srgb, var(--ink) 5%, transparent); +} + +.landing-manuscript__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 50px; + padding: 4px 12px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.landing-manuscript__label { + color: var(--muted); + font: 600 11px/1.3 var(--sans); +} + +.landing-manuscript__views { + display: flex; + flex: 0 0 auto; + gap: 2px; +} + +.landing-manuscript__views button { + min-height: 40px; + padding: 0 9px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font: 600 11px/1.2 var(--sans); + cursor: pointer; +} + +.landing-manuscript__views button:hover { + color: var(--ink); + background: var(--surface-2); +} + +.landing-manuscript__views button[aria-pressed="true"] { + border-color: var(--line); + background: var(--paper); + color: var(--accent); +} + +.landing-manuscript__preview { min-width: 0; } + +.landing-manuscript__paper { + min-height: 226px; + padding: 21px 26px 23px; + color: var(--ink-2); + font: 12px/1.5 var(--display); +} + +.landing-manuscript__paper h2 { + margin: 0 0 15px; + color: var(--ink); + font: 400 clamp(19px, 2.2vw, 23px)/1.15 var(--display); + letter-spacing: -.03em; + text-align: center; +} + +.landing-manuscript__paper h3 { + margin: 0 0 4px; + color: var(--ink); + font: 700 12px/1.4 var(--display); +} + +.landing-manuscript__abstract { margin-bottom: 15px; } +.landing-manuscript__abstract h3 { text-align: center; } +.landing-manuscript__sections { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 22px; } +.landing-manuscript__sections section { min-width: 0; } + +.landing-manuscript__file { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 8px 14px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font: 11px/1.4 var(--sans); +} + +.landing-manuscript__file select { + min-width: 0; + max-width: 100%; + min-height: 40px; + flex: 1; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--paper); + color: var(--ink-2); + font: 11px/1.4 var(--mono); + cursor: pointer; +} + +.landing-manuscript__code { + height: 170px; + overflow: auto; + padding: 13px 16px; + color: var(--ink-2); + font: 11px/1.5 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + scrollbar-color: var(--line-strong) transparent; +} + +.landing-manuscript__code:focus-visible { outline-offset: -3px; } + +@media (max-width: 720px) { + .landing-manuscript__toolbar { padding-inline: 10px; } + .landing-manuscript__views button { min-height: 44px; padding-inline: 8px; } + .landing-manuscript__file select { min-height: 44px; } + .landing-manuscript__paper { padding-inline: 19px; } + .landing-manuscript__sections { gap: 16px; } +} diff --git a/apps/web/src/components/LandingManuscript.test.tsx b/apps/web/src/components/LandingManuscript.test.tsx new file mode 100644 index 0000000..b1a8621 --- /dev/null +++ b/apps/web/src/components/LandingManuscript.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, expect, it } from 'vitest'; +import { LandingManuscript } from './LandingManuscript'; + +afterEach(cleanup); + +it('opens a readable example manuscript and exposes the selected view accessibly', () => { + render(); + const example = screen.getByRole('region', { name: 'Example manuscript' }); + const views = within(example).getByRole('group', { name: 'Example view' }); + expect(within(views).getByRole('button', { name: 'Paper' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(views).getByRole('button', { name: 'Sources' })).toHaveAttribute('aria-pressed', 'false'); + expect(within(example).getByRole('heading', { name: 'A note on collaboration' })).toBeInTheDocument(); + expect(within(example).getByRole('heading', { name: 'Abstract' })).toBeInTheDocument(); + expect(within(example).getByRole('heading', { name: '1. Introduction' })).toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'Example source file' })).not.toBeInTheDocument(); +}); + +it('switches to real modular LaTeX sources and lets the reader inspect each referenced section', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Sources' })); + expect(screen.getByRole('button', { name: 'Sources' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Paper' })).toHaveAttribute('aria-pressed', 'false'); + const main = screen.getByLabelText('main.tex source'); + expect(main).toHaveTextContent(String.raw`\documentclass{article}`); + expect(main).toHaveTextContent(String.raw`\title{A note on collaboration}`); + expect(main).toHaveTextContent(String.raw`\input{sections/introduction}`); + expect(main).toHaveTextContent(String.raw`\input{sections/results}`); + expect(main).toHaveTextContent(String.raw`\end{document}`); + const files = screen.getByRole('combobox', { name: 'Example source file' }); + expect(within(files).getByRole('option', { name: 'sections/introduction.tex' })).toBeInTheDocument(); + expect(within(files).getByRole('option', { name: 'sections/results.tex' })).toBeInTheDocument(); + fireEvent.change(files, { target: { value: '1' } }); + expect(screen.getByLabelText('sections/introduction.tex source')).toHaveTextContent(String.raw`\section{Introduction}`); + expect(screen.getByLabelText('sections/introduction.tex source')).toHaveTextContent('Start with an outline'); + fireEvent.change(files, { target: { value: '2' } }); + expect(screen.getByLabelText('sections/results.tex source')).toHaveTextContent(String.raw`\section{Review}`); + expect(screen.getByLabelText('sections/results.tex source')).toHaveTextContent('Read the sections together'); + fireEvent.click(screen.getByRole('button', { name: 'Paper' })); + expect(screen.getByRole('heading', { name: 'A note on collaboration' })).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/LandingManuscript.tsx b/apps/web/src/components/LandingManuscript.tsx new file mode 100644 index 0000000..5cd5553 --- /dev/null +++ b/apps/web/src/components/LandingManuscript.tsx @@ -0,0 +1,65 @@ +import { useId, useState } from 'react'; +import './LandingManuscript.css'; + +const manuscript = { + title: 'A note on collaboration', + abstract: 'A shared manuscript makes room for ideas, discussion, and revision.', + introduction: 'Start with an outline. Build one section at a time.', + results: 'Read the sections together. Review each change in context.' +}; + +const sourceFiles = [ + { + path: 'main.tex', + content: String.raw`\documentclass{article} +\title{${manuscript.title}} +\date{} +\begin{document} +\maketitle +\begin{abstract} +${manuscript.abstract} +\end{abstract} +\input{sections/introduction} +\input{sections/results} +\end{document}` + }, + { path: 'sections/introduction.tex', content: String.raw`\section{Introduction} +${manuscript.introduction}` }, + { path: 'sections/results.tex', content: String.raw`\section{Review} +${manuscript.results}` } +]; + +/** A small, local example of one manuscript backed by modular LaTeX files. */ +export function LandingManuscript() { + const [view, setView] = useState<'manuscript' | 'sources'>('manuscript'); + const [sourceIndex, setSourceIndex] = useState(0); + const id = useId(); + const source = sourceFiles[sourceIndex]!; + + return
+
+ Example manuscript +
+ + +
+
+
+ {view === 'manuscript' + ?
+

{manuscript.title}

+

Abstract

{manuscript.abstract}

+
+

1. Introduction

{manuscript.introduction}

+

2. Review

{manuscript.results}

+
+
+ :
+ +
{source.content}
+
} +
+
; +} diff --git a/apps/web/src/pages/AuthPage.css b/apps/web/src/pages/AuthPage.css new file mode 100644 index 0000000..e67d30f --- /dev/null +++ b/apps/web/src/pages/AuthPage.css @@ -0,0 +1,84 @@ +.auth-shell { + min-height: 100dvh; + display: grid; + grid-template-rows: auto 1fr; + background: var(--paper); +} + +.auth-head, .auth-main { + width: 100%; + max-width: 1240px; + margin-inline: auto; + padding-inline: clamp(20px, 4vw, 56px); +} + +.auth-head { + min-height: 80px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); +} + +.auth-head__actions { display: flex; align-items: center; gap: 26px; } +.auth-source { min-height: 44px; display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; text-decoration: none; } +.auth-source:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } + +.auth-main { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-areas: "story panel" "example panel"; + column-gap: clamp(40px, 6vw, 88px); + row-gap: 30px; + align-content: center; + padding-block: 32px; +} + +.auth-story { grid-area: story; min-width: 0; } +.auth-story h1 { font: 400 clamp(42px, 4.7vw, 62px)/1.08 var(--display); letter-spacing: -.045em; } +.auth-story > p { max-width: 430px; margin-top: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; } +.auth-example { grid-area: example; min-width: 0; } + +.auth-panel { + grid-area: panel; + align-self: center; + min-width: 0; + padding: 32px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); +} + +.auth-panel h2 { font-size: 28px; font-weight: 600; line-height: 1.2; letter-spacing: -.035em; } +.auth-panel > p { margin-top: 10px; color: var(--muted); font-size: 13px; line-height: 1.6; } +.auth-panel > .primary-button { min-height: 46px; margin-top: 24px; border-radius: 7px; text-decoration: none; } +.auth-panel > .inline-message { margin-top: 20px; } +.auth-panel > .primary-button svg { flex: 0 0 auto; } + +@media (max-width: 980px) { + .auth-main { grid-template-columns: minmax(0, 1fr) 330px; column-gap: 32px; } + .auth-story h1 { font-size: 44px; } + .auth-panel { padding: 26px; } +} + +@media (max-width: 760px) { + .auth-head { min-height: 72px; gap: 12px; } + .auth-head .brand__copy small, .auth-source { display: none; } + .auth-head__actions { gap: 0; } + .auth-main { max-width: 560px; grid-template-columns: minmax(0, 1fr); grid-template-areas: "story" "panel" "example"; gap: 26px; padding-block: 28px 40px; } + .auth-story h1 { font-size: clamp(35px, 6vw, 44px); } + .auth-story > p { max-width: 34ch; margin-top: 14px; font-size: 15px; line-height: 1.55; } + .auth-panel { padding: 24px; } + .auth-panel h2 { font-size: 26px; } +} + +@media (max-width: 390px) { + .auth-head { padding-inline: 16px; } + .auth-main { padding-inline: 16px; } + .auth-panel { padding: 20px; } +} + +@media (any-pointer: coarse) { + .auth-shell { height: 100%; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; touch-action: pan-y; -webkit-overflow-scrolling: touch; } +} diff --git a/apps/web/src/pages/AuthPage.test.tsx b/apps/web/src/pages/AuthPage.test.tsx index 2a334b7..62ca6eb 100644 --- a/apps/web/src/pages/AuthPage.test.tsx +++ b/apps/web/src/pages/AuthPage.test.tsx @@ -1,10 +1,25 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, expect, it, vi } from 'vitest'; import { AuthPage } from './AuthPage'; vi.mock('../lib/session', () => ({ useSession: () => ({ api: { url: (path: string) => `https://overroot.toni.foo${path}` } }) })); vi.mock('../hooks/useTheme', () => ({ useTheme: () => ({ preference: 'system', setPreference: vi.fn() }) })); afterEach(cleanup); +it.each(['login', 'register'] as const)('keeps the %s shared-account return path while exploring modular sources', (mode) => { + const returnTo = '/#/project/opaque?file=sections%2Fintro.tex#source-line-12'; + const { container } = render(); + const signIn = screen.getByRole('link', { name: /Continue to shared accounts/ }); + const href = signIn.getAttribute('href'); + expect(new URL(href!).searchParams.get('returnTo')).toBe(returnTo); + fireEvent.click(screen.getByRole('button', { name: 'Sources' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Example source file' }), { target: { value: '1' } }); + expect(screen.getByLabelText('sections/introduction.tex source')).toHaveTextContent('\\section{Introduction}'); + fireEvent.click(screen.getByRole('button', { name: 'Paper' })); + expect(screen.getByRole('article', { name: 'Sample article' })).toBeInTheDocument(); + expect(signIn).toHaveAttribute('href', href); + expect(container.querySelector('form, input')).toBeNull(); + expect(screen.getByRole('heading', { name: 'Collaborative LaTeX', level: 1 })).toBeInTheDocument(); +}); it.each(['login', 'register'] as const)('%s directs people to central sign-in without collecting passwords', (mode) => { const { container } = render(); expect(screen.getByRole('link', { name: /Continue to shared accounts/ })).toHaveAttribute('href', 'https://overroot.toni.foo/api/v1/auth/start'); @@ -12,6 +27,19 @@ it.each(['login', 'register'] as const)('%s directs people to central sign-in wi expect(screen.getByText(/Request Overroot access/)).toBeInTheDocument(); }); +it('keeps expired-attempt recovery and the safe project target while exploring the example', () => { + const returnTo = '/#/project/opaque?file=sections%2Fintro.tex#source-line-12'; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Sources' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Example source file' }), { target: { value: '2' } }); + expect(screen.getByRole('alert')).toHaveTextContent('This sign-in attempt expired or is invalid. Start a fresh sign-in to continue.'); + const href = new URL(screen.getByRole('link', { name: /Start fresh sign-in/ }).getAttribute('href')!); + expect(href.origin).toBe('https://overroot.toni.foo'); + expect(href.pathname).toBe('/api/v1/auth/start'); + expect([...href.searchParams]).toEqual([['returnTo', returnTo]]); + expect(screen.queryByText(/private-code|private-state/)).toBeNull(); +}); + it.each([ ['invalid_attempt', 'This sign-in attempt expired or is invalid. Start a fresh sign-in to continue.'], ['accounts_unavailable', 'Shared accounts could not complete sign-in. Please try again.'] diff --git a/apps/web/src/pages/AuthPage.tsx b/apps/web/src/pages/AuthPage.tsx index d12c77c..2fc5c7c 100644 --- a/apps/web/src/pages/AuthPage.tsx +++ b/apps/web/src/pages/AuthPage.tsx @@ -1,10 +1,12 @@ -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, Github } from 'lucide-react'; import { useLocation, useSearchParams } from 'react-router-dom'; import { InlineError } from '../components/Feedback'; import { Brand } from '../components/Brand'; +import { LandingManuscript } from '../components/LandingManuscript'; import { ThemeControl } from '../components/ThemeControl'; import { useTheme } from '../hooks/useTheme'; import { useSession } from '../lib/session'; +import './AuthPage.css'; export function AuthPage({ mode }: { mode: 'login' | 'register' }) { const { api } = useSession(); @@ -18,8 +20,13 @@ export function AuthPage({ mode }: { mode: 'login' | 'register' }) { const error = reason === 'invalid_attempt' ? 'This sign-in attempt expired or is invalid. Start a fresh sign-in to continue.' : reason === 'accounts_unavailable' ? 'Shared accounts could not complete sign-in. Please try again.' : null; return
-
-
+
+ + +

Collaborative LaTeX

Write, compile, and share LaTeX projects with people and agents.

@@ -28,6 +35,7 @@ export function AuthPage({ mode }: { mode: 'login' | 'register' }) { {error ? 'Start fresh sign-in' : 'Continue to shared accounts'}
+
; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 796ae08..1f0b632 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -195,27 +195,7 @@ button:disabled { cursor: default; opacity: .42; } .offline-card h1 { margin-top: 15px; font-size: clamp(42px, 7vw, 74px); line-height: .92; letter-spacing: -.06em; } .offline-card p { max-width: 460px; margin: 25px 0 32px; color: var(--muted); font-size: 16px; line-height: 1.55; } -/* Auth */ -.auth-shell { width: 100%; min-height: 100dvh; display: grid; grid-template-rows: 78px 2px 1fr auto; } -.auth-head { display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(22px, 4vw, 60px); border-bottom: 1px solid var(--line); } -.auth-main { width: 100%; max-width: 1320px; min-height: calc(100dvh - 122px); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(350px, .8fr); gap: clamp(40px, 7vw, 110px); align-items: center; padding: 70px clamp(26px, 6vw, 80px); } -.auth-shell { background: var(--surface); } -.auth-story h1 { max-width: 750px; font: 400 clamp(32px, 3.3vw, 46px)/1.15 var(--display); letter-spacing: -.035em; } -.auth-story > p { max-width: 450px; margin-top: 24px; color: var(--muted); font-size: 18px; line-height: 1.65; } -.auth-proof { display: flex; flex-wrap: wrap; gap: 22px; margin-top: 38px; } -.auth-proof span { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; } -.auth-proof svg { color: var(--accent); } -.auth-panel { padding: 32px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); box-shadow: 0 10px 40px #1d302706; } -.auth-panel__label { display: flex; align-items: center; gap: 8px; color: var(--accent); font: 750 11px/1 var(--mono); letter-spacing: .12em; } -.auth-panel h2 { margin-top: 20px; font-size: 30px; font-weight: 600; line-height: 1.2; letter-spacing: -.035em; } -.auth-panel > p { margin-top: 14px; color: var(--muted); font-size: 13px; line-height: 1.55; } -.auth-form { display: grid; gap: 21px; margin-top: 34px; } -.auth-switch { margin-top: 24px; color: var(--muted); font-size: 11px; } -.auth-switch a { color: var(--ink); font-weight: 700; text-underline-offset: 3px; } -.setup-note { display: grid; gap: 6px; margin-top: 20px; padding: 14px; border-left: 2px solid var(--amber); background: var(--amber-soft); font-size: 11px; line-height: 1.5; } -.setup-note span { color: var(--muted); } -.setup-note code { font-family: var(--mono); } -.auth-footer, .dashboard-footer { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 0 clamp(22px, 4vw, 60px); border-top: 1px solid var(--line); color: var(--faint); font: 650 11px/1.3 var(--mono); letter-spacing: .08em; } +.dashboard-footer { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 0 clamp(22px, 4vw, 60px); border-top: 1px solid var(--line); color: var(--faint); font: 650 11px/1.3 var(--mono); letter-spacing: .08em; } /* Dashboard */ .dashboard-shell { min-height: 100dvh; display: grid; grid-template-rows: 78px 2px 1fr auto; background: var(--surface); } @@ -407,9 +387,6 @@ button:disabled { cursor: default; opacity: .42; } } @media (max-width: 1050px) { - .auth-main { grid-template-columns: 1fr; align-content: start; } - .auth-story { padding-top: 20px; } - .auth-panel { width: min(100%, 520px); } .workspace-head { height: 62px; } .workspace-head .brand { display: inline-flex; } .workspace-head .brand__copy { display: none; } @@ -430,15 +407,9 @@ button:disabled { cursor: default; opacity: .42; } .icon-button { width: 44px; height: 44px; } .theme-control { height: 44px; padding: 0; } .theme-control button { width: 42px; height: 42px; } - .auth-shell, .dashboard-shell { grid-template-rows: 66px 2px 1fr auto; } - .auth-head, .dashboard-head { padding-inline: 16px; } - .auth-head .brand__copy small, .dashboard-head .brand__copy small { display: none; } - .auth-main { min-height: 0; gap: 36px; padding: 36px 20px 60px; } - .auth-story h1 { font-size: 34px; } - .auth-story > p { font-size: 17px; } - .auth-proof { gap: 14px; margin-top: 24px; } - .auth-panel { padding: 24px; } - .auth-footer { display: none; } + .dashboard-shell { grid-template-rows: 66px 2px 1fr auto; } + .dashboard-head { padding-inline: 16px; } + .dashboard-head .brand__copy small { display: none; } .dashboard-head__actions .plain-button, .dashboard-head__actions .user-chip { display: none; } .dashboard-head__actions { gap: 5px; } .dashboard-main { padding: 36px 18px 70px; } @@ -502,7 +473,6 @@ button:disabled { cursor: default; opacity: .42; } .dashboard-head .theme-control button { width: 34px; } .brand__mark { width: 28px; height: 28px; } .dashboard-intro__actions { flex-direction: column; } - .auth-story h1 { font-size: 32px; } .pdf-toolbar .icon-button { width: 34px; height: 40px; } .pdf-toolbar .pdf-readout { min-width: 38px; } .rule-row { grid-template-columns: 27px minmax(0, 1fr) auto; }