Add login-page site gate and refresh marketing site for complex-care positioning.
Replace HTTP Basic Auth with a branded /login flow and signed session cookie, using Next.js 16's proxy convention for route protection. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,3 +12,7 @@
|
|||||||
# Example:
|
# Example:
|
||||||
# SERVERVAR="foo"
|
# SERVERVAR="foo"
|
||||||
# NEXT_PUBLIC_CLIENTVAR="bar"
|
# NEXT_PUBLIC_CLIENTVAR="bar"
|
||||||
|
|
||||||
|
# Password required to view the site (login page at /login).
|
||||||
|
# Leave empty/unset to disable the gate.
|
||||||
|
SITE_PASSWORD=""
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import { MedscribeLogo } from "~/components/medscribe-logo";
|
||||||
|
import { PhoneMockup } from "~/components/phone-mockup";
|
||||||
|
import { PrintButton } from "~/components/print-button";
|
||||||
|
import { SectionLabel } from "~/components/section-label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Executive Summary",
|
||||||
|
description:
|
||||||
|
"One-page executive summary for Medscribe, a private medical context app for families managing complex care.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAGLINE =
|
||||||
|
"Your medical truth, in your pocket, and only where you say it goes.";
|
||||||
|
|
||||||
|
const SNAPSHOT =
|
||||||
|
"Medscribe is a private, emergency-ready medical record for families managing complex care. Patients and caregivers keep medications, visits, timing, and baseline current day to day, so a clinician or provider can be handed exactly what matters when the system does not know the patient.";
|
||||||
|
|
||||||
|
const PROBLEM =
|
||||||
|
"Someone managing a serious condition ends up somewhere new: an ER outside their network, a covering doctor, a hospital that runs different software. Their records don't transfer. The team assesses them on how they present, not on their baseline, their exact medication timing, or what changed at their last visit. The family is there and they know, but they can't produce a clear, structured record under pressure.";
|
||||||
|
|
||||||
|
const PROBLEM_BROADER =
|
||||||
|
"This is not a rare edge case. It happens every day, to patients with Parkinson's, diabetes, heart disease, dementia, and a hundred other conditions that require precise, continuous management across a fragmented system. The failure is not that families don't care. The failure is that the patient's context does not travel with them.";
|
||||||
|
|
||||||
|
const DIFFERENTIATION =
|
||||||
|
"The market is full of AI visit recorders, medication trackers, health-record apps, and emergency QR cards, each owning one slice. None owns the whole job. In Medscribe, the daily tools are the maintenance system for one emergency-ready record.";
|
||||||
|
|
||||||
|
const differentiators = [
|
||||||
|
{
|
||||||
|
title: "The record is the product",
|
||||||
|
body: "Recording, scanning, reminders, and Q&A keep one private, emergency-ready record accurate. Competitors ship visit summaries; we ship a record you can hand over in a crisis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Private by architecture",
|
||||||
|
body: "AI runs on-device: no cloud processing, no health-data upload, no tracking, no account. Others say \"encrypted\" while their store disclosures admit cloud collection.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Facts-first, not a guess",
|
||||||
|
body: "Medication answers render trusted drug facts deterministically, deferring dosing and diagnosis to a clinician or pharmacist. Built for complex-care families, not a general health bot.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
title: "The ER that doesn't know you",
|
||||||
|
body: "A patient on time-critical medication ends up at a hospital outside their network. Missing a dose by hours isn't an inconvenience; it's a medical event. Nobody has the schedule, and the family can't produce it under pressure.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The caregiver handoff",
|
||||||
|
body: "An adult child manages a parent's medications, appointments, and history. When they're not there, the context disappears: a sibling visits, a home aide covers, a shift changes. Critical details live in one person's memory.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The new diabetic, overwhelmed",
|
||||||
|
body: "A newly diagnosed patient leaves with insulin timing, diet changes, and follow-ups they half-remember. By the next visit, the provider can't tell what was understood, tried, or missed.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const impact = [
|
||||||
|
{
|
||||||
|
title: "Fewer blank handoffs",
|
||||||
|
body: "When a caregiver or clinician takes over, the facts are already assembled: conditions, medications, allergies, baseline, contacts, and recent changes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Safer medication moments",
|
||||||
|
body: "Time-critical schedules and dose history stay visible, so a missed or delayed medication is easier to catch before it becomes a crisis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Patients and providers aligned",
|
||||||
|
body: "Families see what changed and why; providers get a current snapshot instead of reconstructing it from a stressed family's memory.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const printLightTokens = {
|
||||||
|
"--color-foreground": "#0f172a",
|
||||||
|
"--color-primary": "#0f766e",
|
||||||
|
"--color-surface": "#ffffff",
|
||||||
|
"--color-border-soft": "#e2e8f0",
|
||||||
|
"--color-muted-foreground": "#475569",
|
||||||
|
color: "#0f172a",
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
export default function ExecutiveSummaryPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ===================== SCREEN ===================== */}
|
||||||
|
<div className="print-hidden mx-auto max-w-6xl space-y-14 px-4 py-10 sm:px-6 lg:space-y-20 lg:py-14">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(220px,280px)] lg:gap-14">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionLabel>Executive Summary · June 2026</SectionLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="font-serif text-5xl font-semibold tracking-tight text-foreground sm:text-6xl">
|
||||||
|
Medscribe
|
||||||
|
</h1>
|
||||||
|
<p className="font-serif text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
|
||||||
|
{TAGLINE}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||||
|
{SNAPSHOT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="justify-self-center lg:justify-self-end">
|
||||||
|
<PhoneMockup
|
||||||
|
src="/screenshots/home.png"
|
||||||
|
alt="Medscribe home screen"
|
||||||
|
width={200}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* The Problem */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<SectionLabel>The problem</SectionLabel>
|
||||||
|
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
The patient's context does not travel with them.
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6 leading-relaxed text-muted-foreground lg:grid-cols-2 lg:gap-10">
|
||||||
|
<p>{PROBLEM}</p>
|
||||||
|
<p className="font-medium text-foreground">{PROBLEM_BROADER}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Scenarios */}
|
||||||
|
<section className="space-y-6">
|
||||||
|
<SectionLabel>Who this is for</SectionLabel>
|
||||||
|
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
The moments where context is everything.
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{scenarios.map((scenario) => (
|
||||||
|
<Card key={scenario.title} className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{scenario.title}</CardTitle>
|
||||||
|
<CardDescription>{scenario.body}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Differentiation */}
|
||||||
|
<section className="space-y-6">
|
||||||
|
<SectionLabel>Why this is different</SectionLabel>
|
||||||
|
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
Everyone owns a slice. We own the whole job.
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-4xl leading-relaxed text-muted-foreground">
|
||||||
|
{DIFFERENTIATION}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{differentiators.map((item) => (
|
||||||
|
<Card key={item.title} className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{item.title}</CardTitle>
|
||||||
|
<CardDescription>{item.body}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Impact */}
|
||||||
|
<section className="space-y-6">
|
||||||
|
<SectionLabel>Potential impact</SectionLabel>
|
||||||
|
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
Less panic, more context, better handoffs.
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{impact.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.title}
|
||||||
|
className="card-hover border-primary-button/20 bg-primary-button text-white"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">{item.title}</CardTitle>
|
||||||
|
<CardDescription className="text-white/75">
|
||||||
|
{item.body}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Beta */}
|
||||||
|
<section className="flex flex-col gap-5 rounded-xl border border-border-soft bg-surface p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||||
|
<p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||||
|
A beta is available on iOS through TestFlight. Everything runs
|
||||||
|
privately on the device, so no health data leaves the phone. If
|
||||||
|
you'd like to try it, reach out.
|
||||||
|
</p>
|
||||||
|
<PrintButton size="lg" className="shrink-0" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="border-t border-border-soft pt-6 text-sm text-muted-foreground">
|
||||||
|
Sean O'Connor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===================== PRINT: dense one-page PDF ===================== */}
|
||||||
|
<article
|
||||||
|
style={printLightTokens}
|
||||||
|
className="print-sheet hidden overflow-hidden print:flex print:h-[10.5in] print:flex-col print:justify-between print:px-[0.4in] print:py-[0.25in] print:text-[11pt] print:leading-[1.25]"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="grid grid-cols-[minmax(0,1fr)_1.1in] items-stretch gap-3 border-b border-teal-900/10 pb-2">
|
||||||
|
<div className="flex flex-col justify-between py-0.5">
|
||||||
|
<p className="text-[11pt] font-semibold tracking-[0.15em] text-primary uppercase">
|
||||||
|
Executive Summary · June 2026
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2.5">
|
||||||
|
<MedscribeLogo className="h-[20pt] w-auto text-foreground" />
|
||||||
|
<h1 className="font-serif text-[22pt] leading-none font-semibold tracking-tight text-foreground">
|
||||||
|
Medscribe
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-serif text-[12pt] leading-snug font-medium tracking-tight text-foreground italic">
|
||||||
|
{TAGLINE}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-[11pt] leading-[1.25] text-slate-700">
|
||||||
|
{SNAPSHOT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="h-[2in] w-[0.94in] overflow-hidden rounded-[11px] border-[3px] border-slate-950 bg-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||||
|
<Image
|
||||||
|
src="/screenshots/home.png"
|
||||||
|
alt="Medscribe home screen"
|
||||||
|
width={1206}
|
||||||
|
height={2622}
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
className="h-full w-full object-cover object-top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* The Problem */}
|
||||||
|
<section className="border-b border-teal-900/10 py-1">
|
||||||
|
<h2 className="font-serif text-[12pt] font-semibold tracking-tight text-foreground">
|
||||||
|
The Problem
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-[11pt] leading-[1.25] text-slate-700">
|
||||||
|
{PROBLEM} {PROBLEM_BROADER}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Who This Is For */}
|
||||||
|
<section className="border-b border-teal-900/10 py-1">
|
||||||
|
<h2 className="font-serif text-[12pt] font-semibold tracking-tight text-foreground">
|
||||||
|
Who This Is For
|
||||||
|
</h2>
|
||||||
|
<div className="mt-0.5 grid grid-cols-3 gap-x-4">
|
||||||
|
{scenarios.map((scenario) => (
|
||||||
|
<div key={scenario.title}>
|
||||||
|
<p className="text-[11pt] font-semibold leading-[1.25] text-foreground">
|
||||||
|
{scenario.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11pt] leading-[1.25] text-slate-700">
|
||||||
|
{scenario.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Why This Is Different */}
|
||||||
|
<section className="border-b border-teal-900/10 py-1">
|
||||||
|
<h2 className="font-serif text-[12pt] font-semibold tracking-tight text-foreground">
|
||||||
|
Why This Is Different
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-[11pt] leading-[1.25] text-slate-700">
|
||||||
|
{DIFFERENTIATION}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 grid grid-cols-3 gap-x-4">
|
||||||
|
{differentiators.map((item) => (
|
||||||
|
<p
|
||||||
|
key={item.title}
|
||||||
|
className="text-[11pt] leading-[1.25] text-slate-700"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{item.title}.
|
||||||
|
</span>{" "}
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Impact */}
|
||||||
|
<section className="py-1">
|
||||||
|
<h2 className="font-serif text-[12pt] font-semibold tracking-tight text-foreground">
|
||||||
|
Impact
|
||||||
|
</h2>
|
||||||
|
<div className="mt-0.5 grid grid-cols-3 gap-x-4">
|
||||||
|
{impact.map((item) => (
|
||||||
|
<p
|
||||||
|
key={item.title}
|
||||||
|
className="text-[11pt] leading-[1.25] text-slate-700"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{item.title}.
|
||||||
|
</span>{" "}
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-between border-t border-teal-900/10 pt-1 text-[11pt] text-slate-500">
|
||||||
|
<p>Sean O'Connor</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
A beta is available on iOS through TestFlight.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ScreenBackground } from "~/components/screen-background";
|
||||||
|
import { SiteFooter } from "~/components/site-footer";
|
||||||
|
import { SiteHeader } from "~/components/site-header";
|
||||||
|
|
||||||
|
export default function MarketingLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScreenBackground />
|
||||||
|
<SiteHeader />
|
||||||
|
<main>{children}</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,18 +93,15 @@ export default function HomePage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="grid items-center gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(230px,280px)] lg:gap-14">
|
<section className="grid items-center gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(230px,280px)] lg:gap-14">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="animate-fade-in-up space-y-4">
|
<div className="animate-fade-in-up space-y-5">
|
||||||
<SectionLabel>Private complex-care context</SectionLabel>
|
<SectionLabel>Medscribe · Private complex-care context</SectionLabel>
|
||||||
<h1 className="font-serif text-4xl leading-[1.05] font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl">
|
<h1 className="max-w-2xl font-serif text-4xl leading-[1.05] font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl">
|
||||||
Medscribe
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-xl font-serif text-3xl leading-tight font-semibold tracking-tight text-foreground sm:text-4xl">
|
|
||||||
Be known when care gets complicated
|
Be known when care gets complicated
|
||||||
</p>
|
</h1>
|
||||||
<p className="max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
<p className="max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||||
Medscribe is a private medical context app for families managing
|
A private medical context app for families managing complex care.
|
||||||
complex care. It keeps visits, medications, baseline, contacts,
|
It keeps visits, medications, baseline, contacts, allergies, and
|
||||||
allergies, and emergency facts ready before the chart catches up.
|
emergency facts ready before the chart catches up.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{today}</p>
|
<p className="text-sm text-muted-foreground">{today}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +144,7 @@ export default function HomePage() {
|
|||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
Be known before the handoff breaks
|
The story scatters right when it matters
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-xl leading-relaxed text-muted-foreground">
|
<p className="max-w-xl leading-relaxed text-muted-foreground">
|
||||||
A daughter remembers the medication schedule. A spouse knows what
|
A daughter remembers the medication schedule. A spouse knows what
|
||||||
@@ -181,7 +178,7 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={action.title}
|
key={action.title}
|
||||||
className={`card-hover ${action.featured ? "border-primary/20 bg-primary text-white" : ""}`}
|
className={`card-hover ${action.featured ? "border-primary-button/20 bg-primary-button text-white" : ""}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="gap-3">
|
<CardHeader className="gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -263,7 +260,7 @@ export default function HomePage() {
|
|||||||
<p className="text-sm leading-relaxed text-foreground">
|
<p className="text-sm leading-relaxed text-foreground">
|
||||||
All AI models run on your device. No health data is uploaded for
|
All AI models run on your device. No health data is uploaded for
|
||||||
processing. The medication chatbot stays in drug-information
|
processing. The medication chatbot stays in drug-information
|
||||||
territory — it defers dosing and diagnosis questions to your
|
territory. It defers dosing and diagnosis questions to your
|
||||||
clinician or pharmacist.
|
clinician or pharmacist.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -319,7 +316,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className="animate-fade-in-up">
|
<section className="animate-fade-in-up">
|
||||||
<Card className="overflow-hidden border-primary/20 bg-primary text-white">
|
<Card className="overflow-hidden border-primary-button/20 bg-primary-button text-white">
|
||||||
<CardHeader className="gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardTitle className="text-white">Try Medscribe on iOS</CardTitle>
|
<CardTitle className="text-white">Try Medscribe on iOS</CardTitle>
|
||||||
@@ -333,7 +330,7 @@ export default function HomePage() {
|
|||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-white/30 bg-white text-primary hover:bg-white/90"
|
className="border-white/30 bg-white text-primary-button hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<a href="https://soconnor.dev">
|
<a href="https://soconnor.dev">
|
||||||
Request an invite
|
Request an invite
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
createAccessCookieValue,
|
||||||
|
} from "~/lib/site-auth";
|
||||||
|
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const password = process.env.SITE_PASSWORD;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { password?: string };
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as { password?: string };
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplied = body.password ?? "";
|
||||||
|
if (supplied !== password) {
|
||||||
|
return NextResponse.json({ error: "Incorrect password" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createAccessCookieValue(password);
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { PrintButton } from "~/components/print-button";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Medscribe Executive Summary",
|
|
||||||
description:
|
|
||||||
"One-page executive summary for Medscribe, a private medical context app for families managing complex care.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const proofPoints = [
|
|
||||||
"iOS TestFlight build",
|
|
||||||
"Visit capture and local transcription",
|
|
||||||
"Medication OCR, reminders, and history",
|
|
||||||
"Search and grounded medication questions",
|
|
||||||
"Emergency-ready profile direction",
|
|
||||||
"Full founder ownership",
|
|
||||||
];
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
label: "Problem",
|
|
||||||
title: "Families hold the context care teams need most.",
|
|
||||||
body:
|
|
||||||
"In complex care, the real story often lives outside the chart: what normal looks like, which medication is time-critical, what changed after the last visit, and who can explain it under pressure. Portals store records, but families still perform the handoff from memory.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Product",
|
|
||||||
title: "A private context layer for the person, not the institution.",
|
|
||||||
body:
|
|
||||||
"Medscribe keeps visits, medications, baseline, allergies, contacts, reminders, summaries, and questions under the person they belong to. The goal is not another medical inbox. It is the family-held record that makes someone easier to understand when care gets complicated.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Wedge",
|
|
||||||
title: "Emergency-ready, caregiver-centered, private by architecture.",
|
|
||||||
body:
|
|
||||||
"The market has AI scribes, medication trackers, caregiver portals, and EHR-adjacent tools. Medscribe's wedge is the portable layer families control before an institution is ready: current context, source trails, time-critical medication visibility, and on-device AI where possible.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const opportunities = [
|
|
||||||
{
|
|
||||||
title: "B2C",
|
|
||||||
body: "A caregiver app for families managing aging parents, chronic illness, disability, or high-friction medication routines.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "IP / partnership",
|
|
||||||
body: "A private family-context layer that could matter to care navigation, home health, senior care, pharmacy, or patient-engagement platforms.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Impact",
|
|
||||||
body: "Fewer blank handoffs, safer medication moments, and more confident advocates when someone cannot fully speak for themselves.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExecutiveSummaryPage() {
|
|
||||||
return (
|
|
||||||
<main className="mx-auto max-w-[8.5in] px-4 py-6 sm:px-6 print:p-0">
|
|
||||||
<div className="print-hidden mb-5 flex items-center justify-between gap-4">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
Back to site
|
|
||||||
</Link>
|
|
||||||
<PrintButton />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article className="summary-sheet relative overflow-hidden rounded-lg border border-border-soft bg-surface px-8 py-7 shadow-[0_12px_40px_rgba(15,23,42,0.08)] print:h-[10.5in] print:rounded-none print:border-0 print:px-[0.34in] print:py-[0.3in] print:shadow-none">
|
|
||||||
<header className="relative z-10 grid gap-6 border-b border-teal-900/10 pb-5 lg:grid-cols-[minmax(0,1fr)_190px] print:grid-cols-[minmax(0,1fr)_1.45in] print:gap-4 print:pb-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold tracking-[0.2em] text-primary uppercase print:text-[8.5px]">
|
|
||||||
Executive Summary
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-2 font-serif text-5xl leading-none font-semibold tracking-tight text-foreground print:text-[28px]">
|
|
||||||
Medscribe
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 font-serif text-2xl leading-tight font-semibold tracking-tight text-foreground print:text-[16px]">
|
|
||||||
Be known when care gets complicated.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 max-w-2xl text-base leading-relaxed text-slate-700 print:mt-2 print:text-[10px] print:leading-[1.45]">
|
|
||||||
Medscribe is a private medical context app for families managing
|
|
||||||
complex care. It helps the people closest to a patient keep the
|
|
||||||
living record current: medications, baseline, visits, contacts,
|
|
||||||
allergies, emergency facts, and the source trail behind what changed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center lg:justify-end print:justify-end">
|
|
||||||
<div className="h-[360px] w-[166px] overflow-hidden rounded-[24px] border-[6px] border-slate-950 bg-white shadow-[0_18px_45px_rgba(15,23,42,0.18)] print:h-[2.9in] print:w-[1.34in] print:rounded-[18px] print:border-[4px] print:shadow-[0_10px_22px_rgba(15,23,42,0.16)]">
|
|
||||||
<Image
|
|
||||||
src="/screenshots/home.png"
|
|
||||||
alt="Medscribe home screen"
|
|
||||||
width={1206}
|
|
||||||
height={2622}
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
className="h-full w-full object-cover object-top"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="relative z-10 grid gap-4 py-5 md:grid-cols-3 print:gap-2 print:py-3">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<div key={section.label} className="space-y-2 print:space-y-1">
|
|
||||||
<p className="text-[11px] font-semibold tracking-[0.16em] text-primary uppercase print:text-[8px]">
|
|
||||||
{section.label}
|
|
||||||
</p>
|
|
||||||
<h2 className="font-serif text-xl leading-tight font-semibold tracking-tight text-foreground print:text-[12px]">
|
|
||||||
{section.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm leading-relaxed text-slate-700 print:text-[8.8px] print:leading-[1.38]">
|
|
||||||
{section.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative z-10 grid gap-4 border-t border-teal-900/10 py-5 md:grid-cols-[0.95fr_1.05fr] print:gap-3 print:py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold tracking-[0.16em] text-primary uppercase print:text-[8px]">
|
|
||||||
Current State
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-serif text-2xl leading-tight font-semibold tracking-tight text-foreground print:mt-1 print:text-[14px]">
|
|
||||||
Built enough to show, focused enough to sharpen.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-slate-700 print:text-[8.8px] print:leading-[1.38]">
|
|
||||||
Medscribe is owned and built by Sean O'Connor. Bucknell
|
|
||||||
MedTech Entrepreneurial Fellows contributed market discovery and
|
|
||||||
pitch feedback; they are not founders, owners, or the technical
|
|
||||||
team. The near-term product spine is an emergency-ready profile
|
|
||||||
that can travel with the family across fragmented care settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 print:grid-cols-2 print:gap-1.5">
|
|
||||||
{proofPoints.map((point) => (
|
|
||||||
<div
|
|
||||||
key={point}
|
|
||||||
className="rounded-md border border-teal-900/10 bg-white/78 px-3 py-2 text-sm font-medium text-slate-800 print:px-2 print:py-1.5 print:text-[8.4px]"
|
|
||||||
>
|
|
||||||
{point}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative z-10 grid gap-3 border-t border-teal-900/10 pt-5 md:grid-cols-3 print:gap-2 print:pt-3">
|
|
||||||
{opportunities.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.title}
|
|
||||||
className="rounded-lg bg-teal-950 px-4 py-3 text-white print:rounded-md print:px-2.5 print:py-2"
|
|
||||||
>
|
|
||||||
<h2 className="font-serif text-lg font-semibold tracking-tight print:text-[11px]">
|
|
||||||
{item.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-white/78 print:text-[8.4px] print:leading-[1.32]">
|
|
||||||
{item.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="relative z-10 mt-5 flex flex-col gap-2 border-t border-teal-900/10 pt-4 text-xs text-slate-600 sm:flex-row sm:items-center sm:justify-between print:mt-3 print:pt-2 print:text-[8px]">
|
|
||||||
<p>Sean O'Connor / Founder, full owner</p>
|
|
||||||
<p>Private complex-care context / iOS TestFlight</p>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+5
-11
@@ -3,10 +3,6 @@ import "~/styles/globals.css";
|
|||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
import { Instrument_Sans, Playfair_Display } from "next/font/google";
|
import { Instrument_Sans, Playfair_Display } from "next/font/google";
|
||||||
|
|
||||||
import { ScreenBackground } from "~/components/screen-background";
|
|
||||||
import { SiteFooter } from "~/components/site-footer";
|
|
||||||
import { SiteHeader } from "~/components/site-header";
|
|
||||||
|
|
||||||
const instrumentSans = Instrument_Sans({
|
const instrumentSans = Instrument_Sans({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-instrument-sans",
|
variable: "--font-instrument-sans",
|
||||||
@@ -18,7 +14,10 @@ const playfair = Playfair_Display({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Medscribe — be known when care gets complicated",
|
title: {
|
||||||
|
default: "Medscribe | Be known when care gets complicated",
|
||||||
|
template: "Medscribe | %s",
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
"A private, emergency-ready medical context app for families managing complex care. Keep visits, medications, baseline, allergies, and caregiver handoffs current on device.",
|
"A private, emergency-ready medical context app for families managing complex care. Keep visits, medications, baseline, allergies, and caregiver handoffs current on device.",
|
||||||
icons: {
|
icons: {
|
||||||
@@ -43,12 +42,7 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${instrumentSans.variable} ${playfair.variable}`}>
|
<html lang="en" className={`${instrumentSans.variable} ${playfair.variable}`}>
|
||||||
<body className="min-h-screen">
|
<body className="min-h-screen">{children}</body>
|
||||||
<ScreenBackground />
|
|
||||||
<SiteHeader />
|
|
||||||
<main>{children}</main>
|
|
||||||
<SiteFooter />
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { LoginForm } from "~/components/login-form";
|
||||||
|
import { safeRedirectPath } from "~/lib/site-auth";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sign in",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ from?: string }>;
|
||||||
|
}) {
|
||||||
|
const { from } = await searchParams;
|
||||||
|
|
||||||
|
return <LoginForm redirectTo={safeRedirectPath(from)} />;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Camera,
|
Camera,
|
||||||
|
Check,
|
||||||
ContactRound,
|
ContactRound,
|
||||||
Home,
|
Home,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@@ -21,6 +22,11 @@ const features = [
|
|||||||
title: "One profile for the person you care for",
|
title: "One profile for the person you care for",
|
||||||
description:
|
description:
|
||||||
"Visits, medications, reminders, and questions live together under the person they belong to.",
|
"Visits, medications, reminders, and questions live together under the person they belong to.",
|
||||||
|
points: [
|
||||||
|
"Switch between everyone you care for",
|
||||||
|
"One home for the whole picture",
|
||||||
|
"Nothing scattered across apps",
|
||||||
|
],
|
||||||
image: "/screenshots/home.png",
|
image: "/screenshots/home.png",
|
||||||
alt: "Medscribe home dashboard with record visit, search, medications, and ask shortcuts",
|
alt: "Medscribe home dashboard with record visit, search, medications, and ask shortcuts",
|
||||||
},
|
},
|
||||||
@@ -31,6 +37,11 @@ const features = [
|
|||||||
title: "The context behind the handoff",
|
title: "The context behind the handoff",
|
||||||
description:
|
description:
|
||||||
"Conditions, medication changes, visit summaries, and caregiver notes stay close enough to become useful when time is short.",
|
"Conditions, medication changes, visit summaries, and caregiver notes stay close enough to become useful when time is short.",
|
||||||
|
points: [
|
||||||
|
"Baseline, allergies, and conditions up front",
|
||||||
|
"Time-critical medications flagged",
|
||||||
|
"A source trail behind each fact",
|
||||||
|
],
|
||||||
image: "/screenshots/emergency-record.png",
|
image: "/screenshots/emergency-record.png",
|
||||||
alt: "Medscribe emergency record with critical care context",
|
alt: "Medscribe emergency record with critical care context",
|
||||||
},
|
},
|
||||||
@@ -41,6 +52,11 @@ const features = [
|
|||||||
title: "Visit recordings that refresh the record",
|
title: "Visit recordings that refresh the record",
|
||||||
description:
|
description:
|
||||||
"A visit can become searchable context, so a medication change or follow-up instruction does not depend on memory alone.",
|
"A visit can become searchable context, so a medication change or follow-up instruction does not depend on memory alone.",
|
||||||
|
points: [
|
||||||
|
"Consent-gated, on-device recording",
|
||||||
|
"Searchable transcript and summary",
|
||||||
|
"Follow-ups captured, not forgotten",
|
||||||
|
],
|
||||||
image: "/screenshots/visit-timeline.png",
|
image: "/screenshots/visit-timeline.png",
|
||||||
alt: "Medscribe visit timeline with appointment cards",
|
alt: "Medscribe visit timeline with appointment cards",
|
||||||
},
|
},
|
||||||
@@ -51,6 +67,11 @@ const features = [
|
|||||||
title: "Medication OCR, reminders, and history",
|
title: "Medication OCR, reminders, and history",
|
||||||
description:
|
description:
|
||||||
"Prescription labels, reminders, refill alerts, and dose history help the family see what is supposed to happen and what actually happened.",
|
"Prescription labels, reminders, refill alerts, and dose history help the family see what is supposed to happen and what actually happened.",
|
||||||
|
points: [
|
||||||
|
"Scan labels with the camera",
|
||||||
|
"Reminders and refill alerts",
|
||||||
|
"Dose history at a glance",
|
||||||
|
],
|
||||||
image: "/screenshots/medications.png",
|
image: "/screenshots/medications.png",
|
||||||
alt: "Medscribe medications screen with active medications",
|
alt: "Medscribe medications screen with active medications",
|
||||||
},
|
},
|
||||||
@@ -61,6 +82,11 @@ const features = [
|
|||||||
title: "Medication questions grounded in the record",
|
title: "Medication questions grounded in the record",
|
||||||
description:
|
description:
|
||||||
"Plain-language questions can draw from the person's record and bundled FDA facts, while keeping diagnosis and dosing with clinicians.",
|
"Plain-language questions can draw from the person's record and bundled FDA facts, while keeping diagnosis and dosing with clinicians.",
|
||||||
|
points: [
|
||||||
|
"Plain-language answers",
|
||||||
|
"Grounded in the record and FDA facts",
|
||||||
|
"Defers dosing and diagnosis to clinicians",
|
||||||
|
],
|
||||||
image: "/screenshots/chat.png",
|
image: "/screenshots/chat.png",
|
||||||
alt: "Medscribe Ask chat answering a question about blood pressure medications",
|
alt: "Medscribe Ask chat answering a question about blood pressure medications",
|
||||||
},
|
},
|
||||||
@@ -71,6 +97,11 @@ const features = [
|
|||||||
title: "Private by architecture, not policy",
|
title: "Private by architecture, not policy",
|
||||||
description:
|
description:
|
||||||
"The sensitive work happens on device, so the family record is not another cloud inbox waiting to be mined.",
|
"The sensitive work happens on device, so the family record is not another cloud inbox waiting to be mined.",
|
||||||
|
points: [
|
||||||
|
"On-device AI, no cloud processing",
|
||||||
|
"No inbox to breach or mine",
|
||||||
|
"You control what is ever shared",
|
||||||
|
],
|
||||||
image: "/screenshots/settings.png",
|
image: "/screenshots/settings.png",
|
||||||
alt: "Medscribe settings showing on-device AI models and privacy notice",
|
alt: "Medscribe settings showing on-device AI models and privacy notice",
|
||||||
},
|
},
|
||||||
@@ -81,8 +112,8 @@ export function FeatureShowcase() {
|
|||||||
const active = features.find((f) => f.id === activeId) ?? features[0];
|
const active = features.find((f) => f.id === activeId) ?? features[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(170px,220px)] lg:gap-10">
|
<div className="grid items-center gap-8 lg:grid-cols-[minmax(0,1fr)_300px] lg:gap-12">
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{features.map((feature) => {
|
{features.map((feature) => {
|
||||||
const Icon = feature.icon;
|
const Icon = feature.icon;
|
||||||
@@ -96,7 +127,7 @@ export function FeatureShowcase() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
|
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "border-primary bg-primary text-white shadow-sm"
|
? "border-primary-button bg-primary-button text-white shadow-sm"
|
||||||
: "border-border-soft bg-surface text-muted-foreground hover:border-primary/20 hover:text-foreground",
|
: "border-border-soft bg-surface text-muted-foreground hover:border-primary/20 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -107,22 +138,34 @@ export function FeatureShowcase() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<h3 className="font-serif text-2xl font-semibold tracking-tight text-foreground">
|
<h3 className="font-serif text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
|
||||||
{active.title}
|
{active.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="max-w-xl text-sm leading-relaxed text-muted-foreground sm:text-base">
|
<p className="max-w-xl text-sm leading-relaxed text-muted-foreground sm:text-base">
|
||||||
{active.description}
|
{active.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-x-5 gap-y-2.5 sm:grid-cols-2">
|
||||||
|
{active.points.map((point) => (
|
||||||
|
<li
|
||||||
|
key={point}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug text-foreground"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-primary-light">
|
||||||
|
<Check className="size-3 text-primary" />
|
||||||
|
</span>
|
||||||
|
<span>{point}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full max-w-[220px] items-start justify-center rounded-lg bg-surface-soft/70 p-4 shadow-[0_12px_38px_rgba(15,23,42,0.08)] lg:-mt-16 lg:justify-self-end">
|
<div className="justify-self-center lg:justify-self-end">
|
||||||
<PhoneMockup
|
<div className="flex items-center justify-center rounded-2xl border border-border-soft bg-gradient-to-b from-surface-soft to-primary-light/30 p-6 shadow-[0_18px_45px_rgba(15,23,42,0.10)]">
|
||||||
src={active.image}
|
<PhoneMockup src={active.image} alt={active.alt} width={188} />
|
||||||
alt={active.alt}
|
</div>
|
||||||
width={146}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
import { MedscribeLogo } from "~/components/medscribe-logo";
|
||||||
|
import { ScreenBackground } from "~/components/screen-background";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
|
type LoginFormProps = {
|
||||||
|
redirectTo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginForm({ redirectTo }: LoginFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError("Incorrect password. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(redirectTo);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center px-4 py-16">
|
||||||
|
<ScreenBackground />
|
||||||
|
|
||||||
|
<Card className="w-full max-w-md border-border-soft/80 bg-surface/95 shadow-[0_20px_50px_rgba(15,23,42,0.12)] backdrop-blur-sm">
|
||||||
|
<CardHeader className="items-center space-y-4 text-center">
|
||||||
|
<MedscribeLogo className="h-8 w-[100px] text-foreground" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle className="font-serif text-2xl">
|
||||||
|
Enter site password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This preview site is password protected.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute top-1/2 left-4 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
className="h-12 w-full rounded-[14px] border border-border-soft bg-canvas pr-4 pl-11 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40 focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting || password.length === 0}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Checking..." : "Continue"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
import { Printer } from "lucide-react";
|
import { Printer } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
export function PrintButton() {
|
type PrintButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
size?: ComponentProps<typeof Button>["size"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrintButton({ className, size }: PrintButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button type="button" onClick={() => window.print()} className="print-hidden">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size={size}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className={cn("print-hidden", className)}
|
||||||
|
>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
Print PDF
|
Print PDF
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
export function ScreenBackground() {
|
export function ScreenBackground() {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden print:hidden">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-canvas"
|
className="absolute inset-0 bg-canvas"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(to right, rgba(15,23,42,0.055) 1px, transparent 1px),
|
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(15,23,42,0.055) 1px, transparent 1px)
|
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px)
|
||||||
`,
|
`,
|
||||||
backgroundSize: "28px 28px",
|
backgroundSize: "28px 28px",
|
||||||
}}
|
}}
|
||||||
|
|||||||
+117
-32
@@ -1,48 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FileText, Home, Menu, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileText } from "lucide-react";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { MedscribeLogo } from "~/components/medscribe-logo";
|
import { MedscribeLogo } from "~/components/medscribe-logo";
|
||||||
import { Button } from "~/components/ui/button";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/#features", label: "Features" },
|
{ href: "/", label: "About", icon: Home },
|
||||||
{ href: "/#privacy", label: "Privacy" },
|
{ href: "/executive-summary", label: "Summary", icon: FileText },
|
||||||
{ href: "/#impact", label: "Impact" },
|
|
||||||
{ href: "/executive-summary", label: "Summary" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="print-hidden sticky top-3 z-50 px-3 sm:px-6">
|
<header className="print-hidden sticky top-3 z-50 px-3 sm:px-6">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 rounded-full border border-border-soft/80 bg-canvas/82 px-4 shadow-[0_10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md sm:px-5">
|
<div className="relative mx-auto max-w-5xl">
|
||||||
<Link
|
<div className="flex h-14 items-center justify-between gap-4 rounded-full border border-border-soft/80 bg-canvas/82 px-4 shadow-[0_10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md sm:px-5">
|
||||||
href="/"
|
<Link
|
||||||
className="flex items-center text-foreground transition-opacity hover:opacity-80"
|
href="/"
|
||||||
aria-label="Medscribe home"
|
className="flex items-center text-foreground transition-opacity hover:opacity-80"
|
||||||
|
aria-label="Medscribe home"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<MedscribeLogo className="h-7 w-[88px]" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop nav */}
|
||||||
|
<nav className="hidden items-center gap-6 text-sm sm:flex">
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const isActive = link.href === pathname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"relative py-1 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 -bottom-0.5 h-0.5 origin-center rounded-full bg-primary transition-transform duration-200",
|
||||||
|
isActive ? "scale-x-100" : "scale-x-0",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile menu toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className="relative size-6 text-muted-foreground transition-colors hover:text-foreground focus:outline-none sm:hidden"
|
||||||
|
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 transition-opacity duration-200",
|
||||||
|
isOpen ? "opacity-0" : "opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<X
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 transition-opacity duration-200",
|
||||||
|
isOpen ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile dropdown */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 top-16 overflow-hidden rounded-2xl border border-border-soft/80 bg-canvas/95 shadow-[0_10px_30px_rgba(15,23,42,0.12)] backdrop-blur-md transition-all duration-200 sm:hidden",
|
||||||
|
isOpen
|
||||||
|
? "pointer-events-auto opacity-100"
|
||||||
|
: "pointer-events-none -translate-y-1 opacity-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<MedscribeLogo className="h-7 w-[88px]" />
|
<nav className="flex flex-col p-2">
|
||||||
</Link>
|
{navLinks.map((link) => {
|
||||||
|
const isActive = link.href === pathname;
|
||||||
|
const Icon = link.icon;
|
||||||
|
|
||||||
<nav className="hidden items-center gap-5 text-sm text-muted-foreground md:flex">
|
return (
|
||||||
{navLinks.map((link) => (
|
<Link
|
||||||
<a
|
key={link.href}
|
||||||
key={link.href}
|
href={link.href}
|
||||||
href={link.href}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className="transition-colors hover:text-foreground"
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
className={cn(
|
||||||
{link.label}
|
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
</a>
|
isActive
|
||||||
))}
|
? "bg-primary-light/60 text-foreground"
|
||||||
</nav>
|
: "text-muted-foreground hover:bg-surface-soft hover:text-foreground",
|
||||||
|
)}
|
||||||
<Button asChild size="sm" className="h-8 rounded-full px-3">
|
>
|
||||||
<a href="/executive-summary">
|
<Icon className="size-4" />
|
||||||
<FileText className="size-4" />
|
{link.label}
|
||||||
<span className="hidden sm:inline">Executive summary</span>
|
</Link>
|
||||||
<span className="sm:hidden">Summary</span>
|
);
|
||||||
</a>
|
})}
|
||||||
</Button>
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 -z-10 bg-canvas/30 backdrop-blur-sm transition-opacity duration-200 sm:hidden",
|
||||||
|
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
|
||||||
|
)}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-white shadow-sm hover:bg-primary/90",
|
default:
|
||||||
|
"bg-primary-button text-white shadow-sm hover:bg-primary-button/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-border-soft bg-surface text-foreground hover:bg-surface-soft",
|
"border border-border-soft bg-surface text-foreground hover:bg-surface-soft",
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
|
// Optional site-wide password gate via `/login`, enforced in `src/proxy.ts`.
|
||||||
|
SITE_PASSWORD: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +27,7 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
SITE_PASSWORD: process.env.SITE_PASSWORD,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export const AUTH_COOKIE_NAME = "medscribe_site_access";
|
||||||
|
|
||||||
|
const ACCESS_PAYLOAD = "medscribe-site-access-v1";
|
||||||
|
|
||||||
|
async function signAccessToken(password: string): Promise<string> {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(password),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
"HMAC",
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(ACCESS_PAYLOAD),
|
||||||
|
);
|
||||||
|
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
|
||||||
|
let result = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccessToken(
|
||||||
|
password: string,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const expected = await signAccessToken(password);
|
||||||
|
return timingSafeEqual(expected, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccessCookieValue(
|
||||||
|
password: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return signAccessToken(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeRedirectPath(path: string | null | undefined): string {
|
||||||
|
if (!path?.startsWith("/") || path.startsWith("//")) return "/";
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_COOKIE_NAME,
|
||||||
|
safeRedirectPath,
|
||||||
|
verifyAccessToken,
|
||||||
|
} from "~/lib/site-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide password gate via a login page and signed session cookie.
|
||||||
|
*
|
||||||
|
* Set `SITE_PASSWORD` in `.env` to require a password before the site loads.
|
||||||
|
* Leave `SITE_PASSWORD` unset to disable the gate.
|
||||||
|
*/
|
||||||
|
export async function proxy(req: NextRequest) {
|
||||||
|
const password = process.env.SITE_PASSWORD;
|
||||||
|
|
||||||
|
if (!password) return NextResponse.next();
|
||||||
|
|
||||||
|
const token = req.cookies.get(AUTH_COOKIE_NAME)?.value;
|
||||||
|
const isAuthed = await verifyAccessToken(password, token);
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
if (pathname === "/login" || pathname === "/api/auth") {
|
||||||
|
if (isAuthed && pathname === "/login") {
|
||||||
|
const from = safeRedirectPath(req.nextUrl.searchParams.get("from"));
|
||||||
|
return NextResponse.redirect(new URL(from, req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthed) {
|
||||||
|
const loginUrl = new URL("/login", req.url);
|
||||||
|
const returnPath = pathname + req.nextUrl.search;
|
||||||
|
|
||||||
|
if (returnPath !== "/") {
|
||||||
|
loginUrl.searchParams.set("from", returnPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||||
|
};
|
||||||
+38
-3
@@ -4,11 +4,13 @@
|
|||||||
--font-sans: var(--font-instrument-sans), ui-sans-serif, system-ui, sans-serif;
|
--font-sans: var(--font-instrument-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-serif: var(--font-playfair), ui-serif, Georgia, serif;
|
--font-serif: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
|
|
||||||
|
/* Light palette: mirrors the app's `lightColors` (constants/colors.ts). */
|
||||||
--color-canvas: #f8fafc;
|
--color-canvas: #f8fafc;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-soft: #f1f5f9;
|
--color-surface-soft: #f1f5f9;
|
||||||
--color-primary: #0f766e;
|
--color-primary: #0f766e;
|
||||||
--color-primary-light: #ccfbf1;
|
--color-primary-light: #ccfbf1;
|
||||||
|
--color-primary-button: #0f766e;
|
||||||
--color-foreground: #0f172a;
|
--color-foreground: #0f172a;
|
||||||
--color-muted-foreground: #64748b;
|
--color-muted-foreground: #64748b;
|
||||||
--color-border-soft: #e2e8f0;
|
--color-border-soft: #e2e8f0;
|
||||||
@@ -17,6 +19,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--grid-line: rgba(15, 23, 42, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dark palette: mirrors the app's `darkColors` (constants/colors.ts).
|
||||||
|
* `--color-primary` stays the bright accent (text/icons/borders); filled
|
||||||
|
* buttons use the muted `--color-primary-button` so white text keeps its
|
||||||
|
* contrast, matching the app's primary vs. primaryButton split.
|
||||||
|
*/
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-canvas: #0b1220;
|
||||||
|
--color-surface: #1e293b;
|
||||||
|
--color-surface-soft: #111827;
|
||||||
|
--color-primary: #2dd4bf;
|
||||||
|
--color-primary-light: #134e4a;
|
||||||
|
--color-primary-button: #115e59;
|
||||||
|
--color-foreground: #f1f5f9;
|
||||||
|
--color-muted-foreground: #94a3b8;
|
||||||
|
--color-border-soft: #334155;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-success-light: #064e3b;
|
||||||
|
--grid-line: rgba(45, 212, 191, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
@@ -57,9 +88,13 @@
|
|||||||
animation: fade-in-up 0.5s ease-out 0.3s both;
|
animation: fade-in-up 0.5s ease-out 0.3s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-sheet {
|
/*
|
||||||
|
* Branded one-page PDF sheet: a green-to-white gradient washed over a faint
|
||||||
|
* teal grid. Only rendered for print (the on-screen page mirrors the homepage).
|
||||||
|
*/
|
||||||
|
.print-sheet {
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(240, 253, 250, 0.92), rgba(255, 255, 255, 0.96) 38%, rgba(236, 253, 245, 0.9)),
|
linear-gradient(150deg, #ccfbf1 0%, #f0fdfa 30%, #ffffff 64%),
|
||||||
linear-gradient(rgba(15, 118, 110, 0.07) 1px, transparent 1px),
|
linear-gradient(rgba(15, 118, 110, 0.07) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(15, 118, 110, 0.06) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(15, 118, 110, 0.06) 1px, transparent 1px);
|
||||||
background-size: auto, 22px 22px, 22px 22px;
|
background-size: auto, 22px 22px, 22px 22px;
|
||||||
@@ -96,7 +131,7 @@
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-sheet {
|
.print-sheet {
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user