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.
+
;
}
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; }