style(public): overhaul landing and privacy pages with new layout, hero, and theme effects

Redesign the public landing and privacy pages with a new layout structure,
introducing an aurora background, grain overlay, and updated navigation.
Revise hero section with animated word-level highlighting and refreshed
copy. Update CSS to use semantic theme variables, color-mix, and new
background effects. Adjust navigation, footer, and action button order for
clarity. Update privacy page button label for consistency.
This commit is contained in:
Richard R 2026-05-31 15:55:41 -06:00
parent 14807a0e8c
commit 535d562a0b
4 changed files with 1259 additions and 626 deletions

View file

@ -10,7 +10,9 @@ export default async function PublicLayout({ children }: { children: ReactNode }
return ( return (
<div className="public-shell"> <div className="public-shell">
<div className="public-atmosphere" aria-hidden="true" /> <div className="public-aurora" aria-hidden="true" />
<div className="public-grain" aria-hidden="true" />
<div className="public-frame"> <div className="public-frame">
<div className="public-topbar"> <div className="public-topbar">
<div className="public-wrap"> <div className="public-wrap">
@ -18,30 +20,34 @@ export default async function PublicLayout({ children }: { children: ReactNode }
<Link href="/" className="public-brand" aria-label="OpenReader home"> <Link href="/" className="public-brand" aria-label="OpenReader home">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" /> <img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span> <span className="public-brand-copy">
<span className="public-brand-text">OpenReader</span> <span className="public-brand-text">OpenReader</span>
<span className="public-brand-tag">Read + Listen</span> <span className="public-brand-tag">Read&nbsp;·&nbsp;Listen</span>
</span> </span>
</Link> </Link>
<nav className="public-nav" aria-label="Primary"> <nav className="public-nav" aria-label="Primary">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open App
</Link>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Sign In
</Link>
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Sign Up
</Link>
) : null}
<Link <Link
href="https://docs.openreader.richardr.dev/" href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'sm' })} className="public-nav-link"
> >
Docs Docs
</Link> </Link>
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className="public-nav-link"
>
GitHub
</a>
<span className="public-nav-divider" aria-hidden="true" />
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
Sign in
</Link>
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open app
</Link>
</nav> </nav>
</header> </header>
</div> </div>
@ -52,19 +58,67 @@ export default async function PublicLayout({ children }: { children: ReactNode }
<footer className="public-footer"> <footer className="public-footer">
<div className="public-wrap"> <div className="public-wrap">
<div className="public-footer-inner"> <div className="public-footer-inner">
<p className="public-footer-label">Open source document reader with synchronized text-to-speech playback.</p> <div className="public-footer-brand">
<div className="public-footer-links"> <div className="public-footer-mark">
<Link href="/privacy">Privacy</Link> {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span className="public-brand-text">OpenReader</span>
</div>
<p className="public-footer-label">
An open-source reading room that turns documents into
synchronized, listenable audio that&rsquo;s yours to self-host.
</p>
<div className="public-footer-cta">
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Create account
</Link>
) : null}
<a
href="https://github.com/richardr1126/openreader"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
Star on GitHub
</a>
</div>
</div>
<nav className="public-footer-cols" aria-label="Footer">
<div className="public-footer-col">
<p className="public-footer-col-title">Product</p>
<Link href="/app">Open app</Link>
<Link href="/signin">Sign in</Link>
<a href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer">
Documentation
</a>
</div>
<div className="public-footer-col">
<p className="public-footer-col-title">Project</p>
<a href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer"> <a href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer">
GitHub GitHub
</a> </a>
<a href="https://github.com/richardr1126/openreader/discussions" target="_blank" rel="noopener noreferrer"> <a href="https://github.com/richardr1126/openreader/discussions" target="_blank" rel="noopener noreferrer">
Discussions Discussions
</a> </a>
<a href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer"> <a href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer">
Documentation Issues
</a> </a>
</div> </div>
<div className="public-footer-col">
<p className="public-footer-col-title">Legal</p>
<Link href="/privacy">Privacy &amp; data</Link>
<a href="https://github.com/richardr1126/openreader/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
MIT license
</a>
</div>
</nav>
</div>
<div className="public-footer-base">
<div className="prism-divider" />
<p>© {new Date().getFullYear()} OpenReader · MIT licensed · Self-host friendly</p>
</div> </div>
</div> </div>
</footer> </footer>

View file

@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
@ -42,164 +43,326 @@ export const metadata: Metadata = {
}, },
}; };
// Words that receive the looping read-along highlight sweep in the hero. This
// is the product's actual word-level highlighting, used as the page's signature.
const SWEEP = ['highlighted', 'word', 'by', 'word.'];
const PROVIDERS = ['Kokoro', 'KittenTTS', 'Orpheus', 'OpenAI', 'Replicate', 'DeepInfra'];
const FORMATS = ['EPUB', 'PDF', 'TXT', 'MD', 'DOCX'];
export default async function LandingPage() { export default async function LandingPage() {
const runtimeConfig = await getResolvedRuntimeConfigForRsc(); const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups; const enableUserSignups = runtimeConfig.enableUserSignups;
const instanceBadge = const instanceBadge =
process.env.RICHARDRDEV_PRODUCTION === 'true' process.env.RICHARDRDEV_PRODUCTION === 'true'
? 'Official OpenReader Instance' ? 'Official OpenReader instance'
: 'Open Source Document Reader'; : 'Open-source document reader';
return ( return (
<main className="public-main"> <main className="public-main">
<div className="public-wrap"> {/* ───────────────────────────── Hero ───────────────────────────── */}
<section className="public-panel public-hero-shell public-reveal-1"> <section className="public-hero">
<article className="public-hero-panel"> <div className="public-wrap public-hero-grid">
<p className="public-eyebrow">{instanceBadge}</p> <div className="public-hero-lede public-reveal-1">
<h1 className="public-hero-title"> <p className="public-eyebrow">
Turn static files into a <em>living read-along surface</em>. <span className="public-eyebrow-dot" aria-hidden="true" />
</h1> {instanceBadge}
<p className="public-hero-copy">
OpenReader is a self-host-friendly workspace for EPUB, PDF, TXT, MD, and DOCX. It combines
synchronized speech, fast navigation, and cloud or local TTS providers so long-form reading is easier
to sustain.
</p> </p>
<h1 className="public-hero-title">
Hear every document,
<br />
<span className="public-sweep" aria-hidden="true">
{SWEEP.map((word, i) => (
<span
key={word + i}
className="public-sweep-word"
style={{ '--i': i } as CSSProperties}
>
{word}
{i < SWEEP.length - 1 ? ' ' : ''}
</span>
))}
</span>
<span className="public-visually-hidden">highlighted word by word.</span>
</h1>
<p className="public-hero-copy">
OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
synchronized read-along surface, with genuine text-to-speech,
word-level highlighting, and audiobook export. It&rsquo;s open
source, and entirely yours to self-host.
</p>
<div className="public-actions"> <div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}> <Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
Open Reader Workspace Open the reader
</Link>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'lg' })}>
Sign In
</Link> </Link>
{enableUserSignups ? ( {enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}> <Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Create Account Create account
</Link> </Link>
) : null} ) : (
<Link href="https://docs.openreader.richardr.dev/" className={buttonClass({ variant: 'ghost', size: 'lg' })}> <Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Read Docs Sign in
</Link>
)}
<Link
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link> </Link>
</div> </div>
<dl className="public-meta">
<div className="public-meta-item">
<dt className="public-meta-label">Input formats</dt>
<dd className="public-meta-value">EPUB, PDF, TXT, MD, DOCX</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Read-along</dt>
<dd className="public-meta-value">Word-level sync highlighting</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Providers</dt>
<dd className="public-meta-value">Replicate TTS and OpenAI-compatible</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Export</dt>
<dd className="public-meta-value">Audiobook workflows</dd>
</div>
</dl>
</article>
<aside className="public-signal-panel" aria-label="What makes OpenReader different"> <div className="public-formats" aria-label="Supported formats">
<p className="public-signal-title">Reader pipeline</p> <span className="public-formats-label">Reads</span>
<ul className="public-signal-list"> <div className="public-formats-list">
<li className="public-signal-item"> {FORMATS.map((fmt) => (
<h3>Layout-aware parsing</h3> <span key={fmt} className="public-format-chip">
<p>PDF pages are parsed into structured blocks for precise highlight alignment and consistent sentence boundaries.</p> {fmt}
</li> </span>
<li className="public-signal-item"> ))}
<h3>Segment preloading</h3> </div>
<p>Sentence-level audio segments are generated and cached, reducing stalls while you read continuously.</p> </div>
</li> </div>
<li className="public-signal-item">
<h3>Device sync</h3> {/* Reader mockup: demonstrates the synchronized highlight + playback */}
<p>Progress, queue state, and document settings follow your account across browser sessions and devices.</p> <aside className="public-reader public-reveal-2" aria-label="OpenReader playback preview">
</li> <div className="public-reader-glow" aria-hidden="true" />
<li className="public-signal-item"> <div className="public-reader-bar">
<h3>Self-host path</h3> <span className="public-reader-dot" data-tone="a" />
<p>Run with Docker, pair with S3 or SeaweedFS, and plug in hosted or self-managed TTS endpoints.</p> <span className="public-reader-dot" data-tone="b" />
</li> <span className="public-reader-dot" data-tone="c" />
</ul> <span className="public-reader-file">wizard-of-oz.epub</span>
<span className="public-reader-voice">Kokoro · af_sky</span>
</div>
<div className="public-reader-body">
<p className="public-reader-chapter">Chapter 1 · The Cyclone</p>
<p className="public-reader-text">
Dorothy lived in the midst of the great Kansas prairies, with
Uncle Henry, who was a farmer, and Aunt Em, who was the
farmer&rsquo;s wife.{' '}
<span className="public-reader-sentence">
Their house was small, for the lumber to build it had to be
carried by <span className="public-reader-word">wagon</span>{' '}
many miles.
</span>{' '}
There were four walls, a floor and a roof, which made one room.
</p>
</div>
<div className="public-player">
<button type="button" className="public-player-play" aria-label="Playing" tabIndex={-1}>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
</button>
<div className="public-wave" aria-hidden="true">
{Array.from({ length: 28 }).map((_, i) => (
<span
key={i}
className="public-wave-bar"
style={{ '--b': i } as CSSProperties}
/>
))}
</div>
<span className="public-player-time">04:12 / 18:30</span>
<span className="public-player-speed">1.1×</span>
</div>
</aside> </aside>
</div>
<div className="public-wrap">
<div className="public-providers public-reveal-3" aria-label="Supported text-to-speech providers">
<span className="public-providers-label">Speaks through</span>
<div className="public-providers-track">
{PROVIDERS.map((p) => (
<span key={p} className="public-provider">
{p}
</span>
))}
</div>
</div>
</div>
</section> </section>
<section className="public-section public-reveal-2" aria-labelledby="capability-heading"> {/* ────────────────────────── How it works ───────────────────────── */}
<section className="public-section" aria-labelledby="how-heading">
<div className="public-wrap">
<div className="public-section-head"> <div className="public-section-head">
<h2 id="capability-heading">Engine room capabilities</h2> <p className="public-kicker">The flow</p>
<p>Built for deep reading, not just playback demos.</p> <h2 id="how-heading">From a raw file to a voice in three moves.</h2>
</div> </div>
<div className="public-feature-grid public-rail">
<article className="public-feature-card">
<span className="public-feature-kicker">Parsing</span>
<h3>Structured document understanding</h3>
<p>Geometry-aware parsing gives stable reading order and cleaner highlighting, especially for complex PDF layouts.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Alignment</span>
<h3>ONNX-powered timing maps</h3>
<p>Speech alignment data links each spoken unit back to text so the cursor follows naturally during playback.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Providers</span>
<h3>Cloud + self-hosted voices</h3>
<p>Use OpenAI, DeepInfra, Replicate, or OpenAI-compatible local servers such as Kokoro or KittenTTS.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Distribution</span>
<h3>Audiobook-ready output</h3>
<p>Export chapterized audio and keep metadata clean for downstream players, with resumable processing for large docs.</p>
</article>
</div>
</section>
<section className="public-section public-reveal-3" aria-labelledby="workflow-heading"> <ol className="public-steps">
<div className="public-section-head"> <li className="public-step">
<h2 id="workflow-heading">Typical workflow</h2> <span className="public-step-num">01</span>
<p>Minimal friction from upload to listen.</p> <h3>Upload a document</h3>
</div>
<div className="public-path public-rail">
<article className="public-path-step" data-step="01">
<h3>Drop a file</h3>
<p>Import a document into your library and keep it organized for repeat sessions.</p>
</article>
<article className="public-path-step" data-step="02">
<h3>Choose a voice stack</h3>
<p>Pick provider, model, and speed profile tuned to your reading pace.</p>
</article>
<article className="public-path-step" data-step="03">
<h3>Read, listen, export</h3>
<p>Track progress with synchronized highlighting and export audiobook files when you need offline playback.</p>
</article>
</div>
</section>
<section className="public-panel public-callout public-reveal-3" aria-label="Open source call to action">
<h2>Ship your own private reading stack.</h2>
<p> <p>
Run OpenReader locally or deploy it for your team. The docs cover Docker setup, provider integration, Drop an EPUB, PDF, TXT, Markdown, or DOCX into your library, or
object storage, and compute worker configuration. import one straight from the server, and it stays organized for
every session after.
</p>
</li>
<li className="public-step">
<span className="public-step-num">02</span>
<h3>Pick a voice</h3>
<p>
Choose a provider and model: hosted OpenAI, Replicate, or
DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus
server. Set the speed to your pace.
</p>
</li>
<li className="public-step">
<span className="public-step-num">03</span>
<h3>Read, listen, export</h3>
<p>
Follow word-level highlighting as it plays, pick up where you
left off on any device, and export a chaptered m4b or mp3
audiobook for the road.
</p>
</li>
</ol>
</div>
</section>
{/* ──────────────────────────── Features ─────────────────────────── */}
<section className="public-section" aria-labelledby="features-heading">
<div className="public-wrap">
<div className="public-section-head">
<p className="public-kicker">Under the hood</p>
<h2 id="features-heading">Engineered for deep reading, not playback demos.</h2>
</div>
<div className="public-features">
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Parsing</span>
<h3>Layout-aware PDF understanding</h3>
<p>
PP-DocLayoutV3 (ONNX) detects structured blocks, stitches
content across pages, and aligns highlights to real geometry,
so even dense, multi-column PDFs read in the right order.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Alignment</span>
<h3>Word-by-word timing</h3>
<p>
ONNX Whisper alignment through a JetStream-backed compute worker
maps each spoken word back to the page, so the cursor tracks
speech precisely.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Voices</span>
<h3>Multi-provider TTS</h3>
<p>
Mix cloud APIs with OpenAI-compatible local servers. Bring your
own keys and endpoints, with no lock-in to a single vendor.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Export</span>
<h3>Audiobook output</h3>
<p>
Render chaptered m4b and mp3 files with resumable processing,
ready for any offline player you already use.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Sync</span>
<h3>Progress that follows you</h3>
<p>
Reading position, queue state, and per-document settings sync
across browser sessions and devices through your account.
</p>
</article>
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Backend</span>
<h3>A stack you actually control</h3>
<p>
Run on embedded SeaweedFS or any S3-compatible bucket, back it
with SQLite or Postgres, and ship it with Docker on amd64 or
arm64, with built-in auth and automatic startup migrations.
</p>
</article>
</div>
</div>
</section>
{/* ───────────────────────── Self-host CTA ───────────────────────── */}
<section className="public-section" aria-labelledby="selfhost-heading">
<div className="public-wrap">
<div className="public-callout">
<div className="public-callout-glow" aria-hidden="true" />
<div className="public-callout-copy">
<p className="public-kicker">Open source · MIT</p>
<h2 id="selfhost-heading">Run your own private reading stack.</h2>
<p className="public-callout-text">
Deploy OpenReader for yourself or your team in minutes. The docs
cover Docker, provider integration, object storage, and the
external compute worker. Every piece is yours to host.
</p> </p>
<div className="public-actions"> <div className="public-actions">
<a <a
href="https://github.com/richardr1126/openreader#readme" href="https://github.com/richardr1126/openreader#readme"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })} className={buttonClass({ variant: 'primary', size: 'lg' })}
> >
View Repository View the repository
</a> </a>
<a <a
href="https://docs.openreader.richardr.dev/" href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })} className={buttonClass({ variant: 'outline', size: 'lg' })}
> >
Deployment Guides Deployment guides
</a> </a>
</div> </div>
</section>
</div> </div>
<div className="public-terminal" aria-hidden="true">
<div className="public-terminal-bar">
<span className="public-reader-dot" data-tone="a" />
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-terminal-title">quick start</span>
</div>
<pre className="public-terminal-body">
<code>
<span className="public-term-comment"># pull and run</span>
{'\n'}
<span className="public-term-prompt">$</span> docker run --name openreader \{'\n'}
{' '}-p <span className="public-term-accent">3003:3003</span> -p{' '}
<span className="public-term-accent">8333:8333</span> \{'\n'}
{' '}-e BASE_URL=
<span className="public-term-accent">http://localhost:3003</span> \{'\n'}
{' '}-e AUTH_SECRET=
<span className="public-term-accent">$(openssl rand -hex 32)</span> \{'\n'}
{' '}ghcr.io/richardr1126/openreader:latest{'\n'}
{'\n'}
<span className="public-term-comment"># open the reading room</span>
{'\n'}
<span className="public-term-prompt">$</span> open{' '}
<span className="public-term-accent">localhost:3003</span>
<span className="public-term-caret" />
</code>
</pre>
</div>
</div>
</div>
</section>
</main> </main>
); );
} }

View file

@ -197,7 +197,7 @@ export default async function PrivacyPage() {
Self-Hosting Guide Self-Hosting Guide
</a> </a>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}> <Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to home Back to landing
</Link> </Link>
</div> </div>
</section> </section>

File diff suppressed because it is too large Load diff