diff --git a/README.md b/README.md index b9aebc2..0b98306 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ The apps and the Firestore and Storage rules are what decide what a client can r write, and they are all here: [apps/web/firestore.rules](apps/web/firestore.rules) and [apps/web/storage.rules](apps/web/storage.rules), with their tests beside them. The Cloud Functions are here too, but nothing proves that what runs on pushthat.link is this code, so -that part you take on trust. Pushes are not end-to-end encrypted today: Firebase holds them -as you sent them. [End-to-end encryption](docs/e2ee.md) is the design for changing that, -and none of it has shipped yet. +that part you take on trust. Pushes are end-to-end encrypted: a push's link, title and file +are encrypted on the device with a key made from the account password, and Firebase holds +only ciphertext and the metadata it needs to count, deliver and expire it. [End-to-end +encryption](docs/e2ee.md) is the specification, and `packages/push-test` is the reference +the five clients are checked against. ## Layout diff --git a/apps/web/index.html b/apps/web/index.html index 7562875..24a8b15 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,29 @@ PushThat + + + + + + + + + + +
diff --git a/apps/web/og/card.html b/apps/web/og/card.html new file mode 100644 index 0000000..01d4967 --- /dev/null +++ b/apps/web/og/card.html @@ -0,0 +1,108 @@ + + + + + + + + +
PushThat
+

Send a link to your phone. Or from it.

+

+ Links and files between your phone and your computer, encrypted on the device with the password you already have. +

+
+ + End-to-end encrypted +
+ + diff --git a/apps/web/public/og.png b/apps/web/public/og.png new file mode 100644 index 0000000..cb1ee01 Binary files /dev/null and b/apps/web/public/og.png differ diff --git a/apps/web/src/Layout.tsx b/apps/web/src/Layout.tsx index b79dd95..b980ca1 100644 --- a/apps/web/src/Layout.tsx +++ b/apps/web/src/Layout.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import { ReactNode, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { useLocation, useNavigate } from 'react-router'; import { useAuth } from '@/contexts/AuthContext'; @@ -35,6 +36,13 @@ export default function Layout({ children }: LayoutProps) { return (
+ {/* + react-helmet sets document.title only while some Helmet gives one, and does not put + the old title back when that Helmet unmounts. Without this fallback, a page with no + title of its own (the login page, the list) keeps the title of the last page that + had one, which is usually the front page's long one. + */} +
{isAuthenticated && (
diff --git a/apps/web/src/pages/Index.module.css b/apps/web/src/pages/Index.module.css index 0cde87b..f5569c2 100644 --- a/apps/web/src/pages/Index.module.css +++ b/apps/web/src/pages/Index.module.css @@ -1,185 +1,591 @@ +/* + * The front page. Dark only, like the rest of the app, with the brand pink kept for the + * things that should be looked at first: the primary action, the icons and the step + * numbers. Everything else is white at a few opacities. + */ + .container { + /* + * The page's three shades of secondary text, named so the palette changes in one + * place: body copy, the copy inside cards and steps, and fine print. + */ + --text-body: rgba(255, 255, 255, 0.78); + --text-muted: rgba(255, 255, 255, 0.72); + --text-faint: rgba(255, 255, 255, 0.6); + display: flex; flex-direction: column; - gap: 48px; - padding: 32px 16px; + gap: 96px; + padding: 48px 16px 24px; max-width: 1100px; margin: 0 auto; } +.container h2, +.container h3 { + line-height: 1.15; + margin: 0; +} + +.container p { + margin: 0; +} + +/* ---------- Hero ---------- */ + .hero { + display: grid; + grid-template-columns: minmax(0, 7fr) minmax(0, 6fr); + align-items: center; + gap: 48px; + min-height: min(70vh, 640px); +} + +.heroText { display: flex; flex-direction: column; + gap: 20px; +} + +.eyebrow { + display: inline-flex; align-items: center; - text-align: center; - gap: 12px; + gap: 8px; + width: fit-content; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primary); } -.logo { - width: 72px; - height: 72px; +.eyebrowIcon { + width: 16px; + height: 16px; } .title { - font-size: 40px; - line-height: 1.1; + font-size: clamp(38px, 5vw, 52px); + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.02em; margin: 0; + max-width: 14ch; } .subtitle { - margin: 0; - opacity: 0.8; - font-size: 18px; + font-size: clamp(17px, 1.6vw, 19px); + line-height: 1.55; + color: var(--text-body); + max-width: 56ch; } .ctaRow { display: flex; + flex-wrap: wrap; + align-items: center; gap: 12px; - margin-top: 12px; + margin-top: 8px; } -.ctaButton { - appearance: none; - border: none; - background: #4f46e5; - color: #fff; - padding: 10px 14px; - border-radius: 10px; +/* Matches .button in Button.module.css without the glow, so the row reads as one pair */ +.ctaSecondary { + display: inline-block; + padding: 12px 24px; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); text-decoration: none; + text-transform: uppercase; + letter-spacing: 2px; + font-weight: 500; + transition: border-color 0.25s; +} +.ctaSecondary:hover { + border-color: var(--color-primary); +} + +.finePrint { + font-size: 14px; + color: var(--text-faint); +} + +/* + * The hero picture: a browser window with a phone overlapping its lower-left corner, and + * a pill that travels from the phone's share sheet to the browser's tab. The frames crop + * the real screenshots (object-position) to the part that tells the story: the share + * sheet at the bottom of the phone, the tab and page title at the top of the browser. + */ +.heroVisual { + position: relative; + height: 440px; +} + +.browserFrame { + position: absolute; + top: 0; + left: 28%; + right: 0; + height: 330px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + background: var(--color-surface); +} +.browserFrame img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: left top; +} + +.phoneFrame { + position: absolute; + left: 0; + bottom: 0; + width: 200px; + height: 340px; + border-radius: 26px; + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: + 0 24px 60px rgba(0, 0, 0, 0.55), + 0 0 0 6px #111; + background: var(--color-surface); +} +.phoneFrame img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center bottom; +} + +.pill { + position: absolute; + left: 150px; + top: 300px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + background: var(--color-primary); + color: var(--color-surface); + font-size: 13px; font-weight: 600; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.2), + 0 0 24px color-mix(in srgb, var(--color-primary), transparent 40%); + animation: travel 3.2s ease-in-out infinite; +} +.pill code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} +.pillIcon { + width: 16px; + height: 16px; } -.ctaSecondary { - background: transparent; - color: inherit; - outline: 1px solid currentColor; +/* + * The pill leaves the share sheet, crosses to the browser's tab strip and fades as it + * arrives, then the loop starts again from the sheet. The distance is in px because the + * phone and the pill's start are. The browser frame is placed by percentage, so where the + * pill lands along its tab strip moves as the column widens; it stays over the frame from + * the narrow layout to the widest two-column one, so check that again if the frame moves. + */ +@keyframes travel { + 0% { + transform: translate(0, 0) scale(0.9); + opacity: 0; + } + 12% { + opacity: 1; + transform: translate(0, 0) scale(1); + } + 70% { + opacity: 1; + transform: translate(160px, -280px) scale(1); + } + 82%, + 100% { + opacity: 0; + transform: translate(160px, -280px) scale(0.9); + } } -.row { +@media (prefers-reduced-motion: reduce) { + .pill { + animation: none; + transform: translate(80px, -140px); + } +} + +/* ---------- Sections ---------- */ + +.section { display: flex; - align-items: center; - gap: 16px; + flex-direction: column; + gap: 28px; + scroll-margin-top: 24px; } -.rowAlt { - direction: rtl; +.sectionTitle { + font-size: clamp(28px, 3.4vw, 38px); + font-weight: 700; + letter-spacing: -0.015em; } -.rowAlt > * { - direction: ltr; + +.intro { + font-size: 18px; + line-height: 1.55; + color: var(--text-body); + max-width: 64ch; } -.panel { - min-height: 260px; +/* ---------- How it works ---------- */ + +.steps { + list-style: none; + padding: 0; + margin: 0; display: grid; - place-items: center; - flex-shrink: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.step { + display: flex; + flex-direction: column; + gap: 10px; + padding: 24px; + border-radius: 14px; + border: 1px solid var(--color-border); + background: var(--color-surface); +} +.step h3 { + font-size: 20px; +} +.step p { + color: var(--text-muted); + line-height: 1.55; } -.panelLeft { - padding-left: 140px; +.stepNumber { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: color-mix(in srgb, var(--color-primary), transparent 82%); + color: var(--color-primary); + font-weight: 700; + font-size: 15px; } -.screenshot { - width: auto; - height: auto; - max-height: 400px; - border-radius: 10px; - box-shadow: 0 6px 24px color-mix(in hsl, CanvasText, transparent 90%); +/* ---------- Both directions ---------- */ + +.band { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + align-items: center; + gap: 48px; } -.arrow { - flex: 1; - flex-direction: column; +.bandText { display: flex; - justify-content: center; - align-items: center; + flex-direction: column; + gap: 16px; } -.arrowSvg { +.bandText p { + font-size: 17px; + line-height: 1.6; + color: var(--text-body); +} + +.bandVisual { + position: relative; + height: 380px; +} + +.bandBrowser { + position: absolute; + left: 0; + top: 0; + width: 80%; + height: 300px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + background: var(--color-surface); +} +.bandBrowser img { width: 100%; - height: 48px; + height: 100%; + object-fit: cover; + object-position: right top; } -.arrowLine { - stroke: var(--color-primary); - stroke-width: 6; + +.bandPhone { + position: absolute; + right: 0; + bottom: 0; + width: 190px; + height: 300px; + border-radius: 26px; + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: + 0 24px 60px rgba(0, 0, 0, 0.55), + 0 0 0 6px #111; + background: var(--color-surface); } -.arrowHead { - fill: var(--color-primary); +.bandPhone img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; } -.footer { - margin-top: 12px; - border-top: 1px solid color-mix(in hsl, CanvasText, transparent 90%); +/* ---------- Encryption, features ---------- */ + +.encryption { + padding: 48px; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--color-primary), transparent 60%); + background: radial-gradient( + ellipse at top left, + color-mix(in srgb, var(--color-secondary-1), transparent 55%), + transparent 60% + ), + var(--color-surface); + box-shadow: 0 0 60px color-mix(in srgb, var(--color-primary), transparent 85%); } -.footerInner { + +.cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.card { display: flex; - justify-content: space-between; - align-items: center; - padding-top: 16px; - gap: 12px; + flex-direction: column; + gap: 10px; + padding: 24px; + border-radius: 14px; + border: 1px solid var(--color-border); + background: var(--color-background); +} +.card h3 { + font-size: 18px; +} +.card p { + color: var(--text-muted); + line-height: 1.55; + font-size: 15px; } -.footerNav { display: flex; gap: 12px; } -@media (max-width: 860px) { - .row { - flex-direction: column; - } - .arrow { display: none; } +.cardIcon { + width: 28px; + height: 28px; + color: var(--color-primary); +} + +.honest { + font-size: 15px; + line-height: 1.6; + color: var(--text-muted); + max-width: 72ch; +} +.honest strong { + color: var(--color-text); } +.links { + display: flex; + flex-wrap: wrap; + gap: 24px; + font-weight: 500; +} +/* ---------- Pricing ---------- */ .pricing { - display: flex; - flex-direction: column; align-items: center; - gap: 20px; text-align: center; } -.pricingTitle { - margin: 0; - font-size: 28px; +.pricing .intro { + text-align: center; } + .tiers { display: flex; gap: 20px; flex-wrap: wrap; justify-content: center; + width: 100%; } + .tier { - flex: 1 1 240px; - max-width: 320px; - padding: 24px; - border-radius: 12px; + flex: 1 1 260px; + max-width: 340px; + display: flex; + flex-direction: column; + padding: 28px; + border-radius: 14px; border: 1px solid var(--color-border); background: var(--color-surface); text-align: left; } .tier h3 { - margin: 0 0 8px; + font-size: 18px; + margin-bottom: 8px; } .tier ul { - margin: 16px 0 0; + margin: 20px 0 24px; padding-left: 20px; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; + color: var(--text-body); } .tierPro { border-color: var(--color-primary); box-shadow: 0 0 25px color-mix(in srgb, var(--color-primary), transparent 75%); } +.tierCta { + margin-top: auto; + text-align: center; +} + .price { - margin: 0; - font-size: 32px; - font-weight: 600; + font-size: 36px; + font-weight: 700; + letter-spacing: -0.02em; } .priceNote { font-size: 14px; font-weight: 400; - opacity: 0.8; + letter-spacing: 0; + color: var(--text-faint); } .pricingNote { - margin: 0; max-width: 560px; - opacity: 0.8; + color: var(--text-faint); + font-size: 15px; +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--color-border); +} +.footerInner { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + padding-top: 20px; + gap: 12px; + font-size: 14px; + color: var(--text-faint); +} +.footerBrand { + display: inline-flex; + align-items: center; + gap: 8px; +} +.footerNav { + display: flex; + flex-wrap: wrap; + gap: 16px; +} +.footerNav a { + color: var(--text-body); + text-decoration: none; +} +.footerNav a:hover { + color: var(--color-primary); +} + +/* ---------- Narrow screens ---------- */ + +@media (max-width: 900px) { + .container { + gap: 72px; + padding-top: 24px; + } + .hero, + .band { + grid-template-columns: 1fr; + min-height: 0; + gap: 32px; + } + .band { + display: flex; + flex-direction: column; + } + .steps, + .cards { + grid-template-columns: 1fr; + } + .encryption { + padding: 28px 20px; + } +} + +@media (max-width: 480px) { + .heroVisual { + height: 340px; + } + .browserFrame { + left: 18%; + height: 240px; + } + .phoneFrame { + width: 150px; + height: 250px; + } + .pill { + left: 100px; + top: 230px; + padding: 6px 10px; + } + @keyframes travel { + 0% { + transform: translate(0, 0) scale(0.9); + opacity: 0; + } + 12% { + opacity: 1; + transform: translate(0, 0) scale(1); + } + 70% { + opacity: 1; + transform: translate(90px, -210px) scale(1); + } + 82%, + 100% { + opacity: 0; + transform: translate(90px, -210px) scale(0.9); + } + } + .bandVisual { + height: 300px; + } + .bandBrowser { + width: 90%; + height: 220px; + } + .bandPhone { + width: 140px; + height: 230px; + } + .ctaRow > * { + flex: 1 1 100%; + text-align: center; + } } diff --git a/apps/web/src/pages/Index.tsx b/apps/web/src/pages/Index.tsx index 4191ccb..c5110ba 100644 --- a/apps/web/src/pages/Index.tsx +++ b/apps/web/src/pages/Index.tsx @@ -1,15 +1,216 @@ +import { + BellAlertIcon, + BoltIcon, + CodeBracketIcon, + DevicePhoneMobileIcon, + DocumentArrowUpIcon, + KeyIcon, + LockClosedIcon, + MagnifyingGlassIcon, + ShieldCheckIcon, +} from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { Helmet } from 'react-helmet'; import { Link } from 'react-router'; -import styles from './Index.module.css'; import { GlowLink } from '@/components/GlowLink'; import { LIMITS, PRO_PRICE_USD, REFUND_DAYS, formatMb } from '@/config/plan'; -import clsx from 'clsx'; + +import styles from './Index.module.css'; + +/** The public repository: the footer and the encryption section point readers at the code and the design. */ +const SOURCE_URL = 'https://github.com/MakeRocketGoNow/pushthat'; +const E2EE_SPEC_URL = `${SOURCE_URL}/blob/main/docs/e2ee.md`; + +/** + * The hero's picture: the Android share sheet feeding a tab on the desktop, with an + * encrypted "pill" traveling between them. The screenshots are the real apps; the pill + * is the one thing drawn, because the point it makes (what travels is ciphertext) is + * not something a screenshot can show. + */ +function HeroVisual() { + return ( +
+
+ +
+
+ +
+
+ + 9f3a…c17e +
+
+ ); +} + +function HowItWorks() { + const steps = [ + { + title: 'Sign in everywhere', + body: 'One account on your phone, your computer and Chrome. Your password unlocks the key on each device, and it never leaves them.', + }, + { + title: 'Share it', + body: 'Tap PushThat in the share sheet on your phone. On a computer, paste a link or pick a file in PushThat on the web or in the Chrome extension. A link, a page, a photo, a PDF.', + }, + { + title: 'It is already there', + body: 'With the Chrome extension, a link opens in a new tab on your computer. A push from the browser arrives on your phone as a notification. Every push lands in one searchable list on every device.', + }, + ]; + return ( +
+

How it works

+
    + {steps.map((step, i) => ( +
  1. + {i + 1} +

    {step.title}

    +

    {step.body}

    +
  2. + ))} +
+
+ ); +} + +/** The reverse direction, so that nobody reads the hero as "phone to computer" only. */ +function BothDirections() { + return ( +
+
+

Both directions

+

+ Push a page from Chrome or the web app and your phone buzzes. Open the notification and the link is there, in + the browser you use on the phone. Found something on the phone instead? Share it, and it is a tab in Chrome + before you have reached the keyboard. +

+
+
+
+ +
+
+ +
+
+
+ ); +} + +interface Card { + Icon: typeof KeyIcon; + title: string; + body: string; +} + +/** The icon-title-sentence grid the encryption section and the features share. */ +function CardGrid({ cards }: { cards: Card[] }) { + return ( +
+ {cards.map(({ Icon, title, body }) => ( +
+ +

{title}

+

{body}

+
+ ))} +
+ ); +} + +function Encryption() { + const points = [ + { + Icon: KeyIcon, + title: 'Your password is the key', + body: 'The app turns your password into the key on the device. There is no second passphrase to set up and nothing to switch on: it is on for every push, from the first.', + }, + { + Icon: MagnifyingGlassIcon, + title: 'Search without reading', + body: 'Search runs on scrambled tokens made with your key, so the server can match a search without ever seeing the words.', + }, + { + Icon: ShieldCheckIcon, + title: 'A recovery key, shown once', + body: 'Forget your password and the recovery key keeps your history. Without it, nobody can read the old pushes, and that includes us.', + }, + ]; + return ( +
+

End-to-end encrypted

+

Encrypted before it leaves your hand.

+

+ A push’s link, its title, a file’s name and its contents are encrypted on your device and opened only on the + devices you sign in to. Our servers, Google’s, and anyone with access to either hold only what they cannot read. +

+ +

+ What we can still see: which account pushed, when, whether it was a link or a file, roughly how + big a file is, which of your devices sent it and how many pushes you make. Search also shows us when two of your + pushes share a word, though not what the word is. The monthly limits, file expiry and notifications need these. + The design is published, so you can check it rather than take our word for it. +

+

+ The security page + + The specification + +

+
+ ); +} + +function Features() { + const features = [ + { + Icon: BoltIcon, + title: 'Opens before you get there', + body: 'The Chrome extension opens a pushed link in a new tab the moment it arrives. No click, and no site tab left open to do it.', + }, + { + Icon: BellAlertIcon, + title: 'Your phone, from the browser', + body: 'Push a page from Chrome or the web app and it arrives on your phone as a notification that opens the link.', + }, + { + Icon: DocumentArrowUpIcon, + title: 'Files too', + body: `Photos, PDFs, anything up to ${formatMb(LIMITS.pro.fileBytes)} on Pro (${formatMb(LIMITS.free.fileBytes)} on Free). Stored under a random name, encrypted like everything else, and gone when they expire.`, + }, + { + Icon: MagnifyingGlassIcon, + title: 'One list, searchable', + body: 'Everything you have pushed, on every device, with search. Links stay until you delete them.', + }, + { + Icon: DevicePhoneMobileIcon, + title: 'Every device you own', + body: 'The web today, then Android, iPhone, Mac and a Chrome extension as each reaches its store. The same account and the same list on all of them.', + }, + { + Icon: CodeBracketIcon, + title: 'Source available', + body: 'The apps, the rules that guard the database and the encryption design are public. Read them, build them, or run your own.', + }, + ]; + return ( +
+

What it does

+ +
+ ); +} /** The two tiers side by side (docs/billing.md at the repository root). The numbers are the functions' and the rules'. */ function Pricing() { return ( -
-

Pricing

+
+

Pricing

+

Free is enough for a link or two most days. Pro is for people who live in it.

Free

@@ -20,6 +221,9 @@ function Pricing() {
  • Files up to {formatMb(LIMITS.free.fileBytes)}
  • Files kept {LIMITS.free.keepDays} days
  • + + Sign up free +

    Pro

    @@ -32,6 +236,9 @@ function Pricing() {
  • Files up to {formatMb(LIMITS.pro.fileBytes)}
  • Files kept {LIMITS.pro.keepDays} days
  • + + Sign up, then upgrade +

    @@ -45,71 +252,60 @@ function Pricing() { export function IndexPage() { return (

    + + PushThat: send links and files between your devices, encrypted + +
    - PushThat logo -

    PushThat

    -

    The easiest way to send links and files between Android and your other devices.

    -
    - {/* - Add Chrome Extension - */} - - Signup for free - +
    +

    + + End-to-end encrypted. Source available. +

    +

    Send a link to your phone. Or from it.

    +

    + PushThat moves links and files between your phone and your computer. Share from Android or iPhone and it + opens in Chrome before you reach the keyboard, and waits in the list on the web and on your Mac. Everything + is encrypted on the device with the password you already have, so nobody in between can read it. Including + us. +

    +
    + + Get started free + + + How it’s encrypted + +
    +

    + Free plan, no card. The web today; Android, iPhone, Mac and Chrome as they reach the stores. +

    +
    -
    -
    - Share from Android -
    - -
    - Android to Desktop - - - - -
    - -
    - Receive on desktop browser -
    -
    - -
    -
    - Receive on Android (notification) -
    - -
    - Desktop to Android - - - - -
    - -
    - Share from desktop browser -
    -
    - + + + +
    ); } - - diff --git a/docs/README.md b/docs/README.md index 31736e4..6e7c211 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,5 +13,5 @@ house style ([AGENTS.md](../AGENTS.md)). and what stays manual. - [Billing](billing.md): the plan, the Stripe accounts, and how the functions and the rules enforce it. -- [End-to-end encryption](e2ee.md): the specification for encrypting pushes. A design; none - of it has shipped yet. +- [End-to-end encryption](e2ee.md): the specification for encrypting pushes, which every + client implements and the rules require.