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
126 changes: 126 additions & 0 deletions apps/web/src/components/LandingManuscript.css
Original file line number Diff line number Diff line change
@@ -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; }
}
42 changes: 42 additions & 0 deletions apps/web/src/components/LandingManuscript.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LandingManuscript/>);
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(<LandingManuscript/>);
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();
});
65 changes: 65 additions & 0 deletions apps/web/src/components/LandingManuscript.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="landing-manuscript" aria-label="Example manuscript">
<div className="landing-manuscript__toolbar">
<span className="landing-manuscript__label">Example manuscript</span>
<div className="landing-manuscript__views" role="group" aria-label="Example view">
<button type="button" aria-pressed={view === 'manuscript'} aria-controls={`${id}-preview`} onClick={() => setView('manuscript')}>Paper</button>
<button type="button" aria-pressed={view === 'sources'} aria-controls={`${id}-preview`} onClick={() => setView('sources')}>Sources</button>
</div>
</div>
<div id={`${id}-preview`} className="landing-manuscript__preview">
{view === 'manuscript'
? <article className="landing-manuscript__paper" aria-label="Sample article">
<h2>{manuscript.title}</h2>
<div className="landing-manuscript__abstract"><h3>Abstract</h3><p>{manuscript.abstract}</p></div>
<div className="landing-manuscript__sections">
<section><h3>1. Introduction</h3><p>{manuscript.introduction}</p></section>
<section><h3>2. Review</h3><p>{manuscript.results}</p></section>
</div>
</article>
: <div className="landing-manuscript__sources">
<label className="landing-manuscript__file"><span>File</span><select aria-label="Example source file" value={sourceIndex} onChange={(event) => setSourceIndex(Number(event.target.value))}>
{sourceFiles.map((file, index) => <option key={file.path} value={index}>{file.path}</option>)}
</select></label>
<pre className="landing-manuscript__code" tabIndex={0} aria-label={`${source.path} source`}><code>{source.content}</code></pre>
</div>}
</div>
</section>;
}
84 changes: 84 additions & 0 deletions apps/web/src/pages/AuthPage.css
Original file line number Diff line number Diff line change
@@ -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; }
}
30 changes: 29 additions & 1 deletion apps/web/src/pages/AuthPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
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(<MemoryRouter initialEntries={[{ pathname: `/${mode}`, state: { returnTo } }]}><AuthPage mode={mode}/></MemoryRouter>);
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(<MemoryRouter><AuthPage mode={mode}/></MemoryRouter>);
expect(screen.getByRole('link', { name: /Continue to shared accounts/ })).toHaveAttribute('href', 'https://overroot.toni.foo/api/v1/auth/start');
expect(container.querySelector('input')).toBeNull();
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(<MemoryRouter initialEntries={[{ pathname: '/login', search: '?authError=invalid_attempt&code=private-code&state=private-state', state: { returnTo } }]}><AuthPage mode="login"/></MemoryRouter>);
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.']
Expand Down
Loading
Loading