Polish the API-key gate
- Add a theme toggle to the gate (sun/moon ghost button next to the help button); it switches instantly and persists to localStorage. - Strengthen the corner glows so the stage reads atmospheric in both themes (bigger, slightly stronger radial gradients behind the card). - Center the logo, heading, subtitle and footnote inside the card. - Dark mode: replace the solid white logo tile with a subtle raised tile (thin border, light snowflake); make the card only slightly lighter than the near-black page with a low-opacity border and soft shadow. - Disabled Continue keeps the primary fill at ~45% opacity with a crisp label instead of a faded muddy one; fully solid once text is entered. - Bigger 36px corner buttons and a visible "How do I get a key?" text link under the input that opens the same modal. - Shorten and balance the reassurance line. - Help modal: step circles aligned to the first text line, emphasised free-tier callout with a check icon, underlined links with an external-link icon, muted medium-weight UI labels, 36px close button aligned to the title. - Gate test extended to 44 checks (theme toggle, text link, modal polish).
This commit is contained in:
parent
dfa7b7502a
commit
fee59b9577
3 changed files with 143 additions and 67 deletions
|
|
@ -103,12 +103,15 @@
|
|||
--sidebar-bottom: #f6f6f7;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
|
||||
/* API-key gate: near-white stage with soft blank glows, tinted input. */
|
||||
--gate-base: #f7f7f8;
|
||||
--gate-glow-a: rgb(64 74 90 / 0.1);
|
||||
--gate-glow-b: rgb(96 108 124 / 0.07);
|
||||
--gate-base: #f6f6f8;
|
||||
--gate-glow-a: rgb(88 106 132 / 0.16);
|
||||
--gate-glow-b: rgb(126 140 160 / 0.12);
|
||||
--gate-card: #ffffff;
|
||||
--gate-card-border: #e7e7e9;
|
||||
--gate-callout-border: #d9d9dc;
|
||||
--field: #f3f3f4;
|
||||
--field-border: #e2e2e4;
|
||||
--shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.35), 0 1px 2px rgb(13 13 13 / 0.05);
|
||||
--shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.3), 0 1px 2px rgb(13 13 13 / 0.05);
|
||||
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04);
|
||||
--shadow-card-hover: 0 10px 28px -14px rgb(13 13 13 / 0.22), 0 1px 2px rgb(13 13 13 / 0.06);
|
||||
}
|
||||
|
|
@ -131,12 +134,15 @@
|
|||
--sidebar-bottom: #151515;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
|
||||
/* API-key gate: near-black stage, monochrome glows, raised input. */
|
||||
--gate-base: #0d0d0e;
|
||||
--gate-glow-a: rgb(255 255 255 / 0.075);
|
||||
--gate-glow-b: rgb(255 255 255 / 0.05);
|
||||
--field: #383838;
|
||||
--field-border: rgb(255 255 255 / 0.12);
|
||||
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.9), 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--gate-base: #0c0c0d;
|
||||
--gate-glow-a: rgb(255 255 255 / 0.14);
|
||||
--gate-glow-b: rgb(255 255 255 / 0.09);
|
||||
--gate-card: #171718;
|
||||
--gate-card-border: rgb(255 255 255 / 0.08);
|
||||
--gate-callout-border: rgb(255 255 255 / 0.2);
|
||||
--field: #242425;
|
||||
--field-border: rgb(255 255 255 / 0.1);
|
||||
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
|
@ -191,8 +197,8 @@ body {
|
|||
softest "blurred glow" available without extra layers. */
|
||||
.gate-bg {
|
||||
background:
|
||||
radial-gradient(64rem 46rem at 112% -12%, var(--gate-glow-a), transparent 68%),
|
||||
radial-gradient(58rem 42rem at -12% 112%, var(--gate-glow-b), transparent 70%),
|
||||
radial-gradient(72rem 54rem at 108% -14%, var(--gate-glow-a), transparent 70%),
|
||||
radial-gradient(64rem 48rem at -14% 112%, var(--gate-glow-b), transparent 72%),
|
||||
var(--gate-base);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,18 @@
|
|||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { EyeIcon, EyeOffIcon, QuestionIcon, SnowflakeIcon, XIcon } from "@/components/icons";
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
MoonIcon,
|
||||
QuestionIcon,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
XIcon,
|
||||
} from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { verifyOllamaKey } from "@/lib/client-api";
|
||||
import { saveSettings, settingsStore } from "@/lib/settings";
|
||||
|
|
@ -40,8 +51,34 @@ export function ApiKeyGate({ children }: { children: React.ReactNode }) {
|
|||
return <GateScreen />;
|
||||
}
|
||||
|
||||
/** Ghost icon button used for the two top-right controls. */
|
||||
function CornerButton({
|
||||
label,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
className="grid size-9 place-items-center rounded-full border border-[var(--field-border)] bg-[var(--gate-card)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GateScreen() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const { theme, toggle } = useTheme();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -82,42 +119,50 @@ function GateScreen() {
|
|||
|
||||
return (
|
||||
<div className="gate-bg relative min-h-dvh w-full overflow-hidden">
|
||||
{/* help -------------------------------------------------------- */}
|
||||
{/* corner controls: theme + help ---------------------------------- */}
|
||||
<div
|
||||
className="group fixed z-20"
|
||||
className="fixed z-20 flex items-center gap-2"
|
||||
style={{
|
||||
top: "max(env(safe-area-inset-top), 1rem)",
|
||||
right: "max(env(safe-area-inset-right), 1rem)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
aria-label="How do I get an Ollama API key?"
|
||||
title="How do I get a key?"
|
||||
className="grid size-8 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
<CornerButton
|
||||
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
onClick={toggle}
|
||||
>
|
||||
<QuestionIcon className="size-4" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
How do I get a key?
|
||||
</span>
|
||||
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
|
||||
</CornerButton>
|
||||
|
||||
<div className="group relative">
|
||||
<CornerButton
|
||||
label="How do I get an Ollama API key?"
|
||||
title="How do I get a key?"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<QuestionIcon className="size-[18px]" />
|
||||
</CornerButton>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
How do I get a key?
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* card -------------------------------------------------------- */}
|
||||
{/* card ----------------------------------------------------------- */}
|
||||
<div className="relative z-10 flex min-h-dvh items-center justify-center px-5 py-10">
|
||||
<motion.div
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="w-full max-w-[420px] rounded-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 shadow-[var(--shadow-gate)] sm:p-8"
|
||||
className="w-full max-w-[420px] rounded-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 text-center shadow-[var(--shadow-gate)] sm:p-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-14 place-items-center rounded-[16px] bg-[var(--text)] text-[var(--page)]"
|
||||
className="mx-auto grid size-14 place-items-center rounded-[16px] bg-[var(--text)] text-[var(--page)] dark:border dark:border-[var(--field-border)] dark:bg-[var(--field)] dark:text-[var(--text)]"
|
||||
>
|
||||
<SnowflakeIcon className="size-7" />
|
||||
</span>
|
||||
|
|
@ -125,7 +170,7 @@ function GateScreen() {
|
|||
<h1 className="mt-6 text-[26px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
|
||||
Connect your Ollama key
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
<p className="mx-auto mt-1.5 max-w-[300px] text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
One step and you’re ready to freeze your first deck.
|
||||
</p>
|
||||
|
||||
|
|
@ -150,7 +195,7 @@ function GateScreen() {
|
|||
disabled={busy}
|
||||
aria-invalid={error?.tone === "invalid" || undefined}
|
||||
aria-describedby={error ? "gate-api-key-error" : undefined}
|
||||
className="h-12 w-full rounded-[13px] border border-[var(--field-border)] bg-[var(--field)] pl-3.5 pr-12 text-[14px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-[var(--text-subtle)] disabled:opacity-60"
|
||||
className="h-12 w-full rounded-[13px] border border-[var(--field-border)] bg-[var(--field)] pl-3.5 pr-12 text-left text-[14px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-[var(--text-subtle)] disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -178,10 +223,20 @@ function GateScreen() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="text-[12.5px] font-medium text-[var(--text-muted)] underline-offset-2 transition hover:text-[var(--text)] hover:underline"
|
||||
>
|
||||
How do I get a key?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || busy}
|
||||
className="btn-primary mt-3.5 h-12 w-full rounded-[13px] text-[14.5px] font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="btn-primary mt-3.5 h-12 w-full rounded-[13px] text-[14.5px] font-medium disabled:cursor-not-allowed disabled:bg-[rgb(13_13_13_/_0.45)] dark:disabled:bg-[rgb(255_255_255_/_0.45)]"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
|
|
@ -193,8 +248,8 @@ function GateScreen() {
|
|||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||
Your key stays in this browser. Freezmo’s server never stores it.
|
||||
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||
Your key stays in this browser. Our server never stores it.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -230,11 +285,11 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="thin-scroll max-h-[90dvh] w-full max-w-md overflow-y-auto rounded-t-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 pb-safe shadow-[var(--shadow-gate)] sm:rounded-[24px] sm:pb-6"
|
||||
className="thin-scroll max-h-[90dvh] w-full max-w-md overflow-y-auto rounded-t-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 pb-safe shadow-[var(--shadow-gate)] sm:rounded-[24px] sm:pb-6"
|
||||
>
|
||||
<div className="sheet-grabber sm:hidden" aria-hidden />
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2
|
||||
id="gate-help-title"
|
||||
className="text-[19px] font-medium leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
|
|
@ -245,44 +300,35 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="grid size-8 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
className="grid size-9 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3 text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
You only need the free cloud tier. No payment and no credit card required.
|
||||
</p>
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--surface-sunken)] px-3.5 py-3">
|
||||
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
|
||||
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
You only need the{" "}
|
||||
<strong className="font-semibold text-[var(--text)]">free cloud tier</strong>. No
|
||||
payment and no credit card required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="mt-5 space-y-4">
|
||||
<Step index={1}>
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://ollama.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-[var(--text)] underline underline-offset-2"
|
||||
>
|
||||
ollama.com
|
||||
</a>{" "}
|
||||
and sign in, or create a free account.
|
||||
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink> and sign in, or
|
||||
create a free account.
|
||||
</Step>
|
||||
<Step index={2}>
|
||||
Open <span className="text-[var(--text)]">Settings → API keys</span> at{" "}
|
||||
<a
|
||||
href={KEYS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-[var(--text)] underline underline-offset-2"
|
||||
>
|
||||
ollama.com/settings/keys
|
||||
</a>
|
||||
.
|
||||
Open Settings → API keys at{" "}
|
||||
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>.
|
||||
</Step>
|
||||
<Step index={3}>
|
||||
Click <span className="text-[var(--text)]">“Create new key”</span>, give it
|
||||
any name, and copy it.
|
||||
Click{" "}
|
||||
<span className="font-medium text-[var(--text-muted)]">“Create new key”</span>,
|
||||
give it any name, and copy it.
|
||||
</Step>
|
||||
<Step index={4}>Paste it into Freezmo and press Continue.</Step>
|
||||
</ol>
|
||||
|
|
@ -306,16 +352,30 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-[var(--text)] underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
<ExternalLinkIcon className="size-3.5 opacity-60" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ index, children }: { index: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className="flex gap-3">
|
||||
<li className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-full border border-[var(--hairline)] text-[11.5px] font-medium tabular-nums text-[var(--text-subtle)]"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-full border border-[var(--hairline)] text-[11.5px] font-medium tabular-nums text-[var(--text-subtle)]"
|
||||
>
|
||||
{index}
|
||||
</span>
|
||||
<span className="text-[13px] leading-relaxed text-[var(--text-muted)]">{children}</span>
|
||||
<span className="text-[13px] leading-6 text-[var(--text-muted)]">{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,3 +275,13 @@ export function XIcon({ className }: IconProps) {
|
|||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalLinkIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14 4h6v6" />
|
||||
<path d="m20 4-8.5 8.5" />
|
||||
<path d="M18 13.5V19a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue