Gate polish round two: glows, disabled state, help link, modal

- Disabled Continue is now the primary style at 45% opacity with
  cursor: not-allowed; full opacity while validating and once text is
  entered.
- Move "How do I get a key?" below the Continue button (small ? icon,
  muted, underline on hover) and tighten input+button spacing.
- Shorten the subtitle to "One step to freeze your first deck." and
  balance it.
- Replace the faint gradient with two real blurred blobs (top-right and
  bottom-left, 120px blur, pointer-events: none, behind the card).
- Dark: page #0f0f0f, card #1a1a1a, 8% white border.
- Phone: 24px heading, 15px subtitle, wider card padding, 40px corner
  buttons with safe-area-aware offsets, robust 100dvh centering.
- Modal: 480px wide with text-pretty steps, stronger tinted free-tier
  callout, handle removed from the phone sheet.
- Tests: gate suite now 49 checks incl. disabled opacity, glow pseudo,
  card separation and help-link placement; new state screenshots for
  invalid/offline/validating in both themes.
This commit is contained in:
Martin Dvořák 2026-10-07 06:09:13 +00:00
commit 969f7a82a0
2 changed files with 62 additions and 39 deletions

View file

@ -104,11 +104,12 @@
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28); --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. */ /* API-key gate: near-white stage with soft blank glows, tinted input. */
--gate-base: #f6f6f8; --gate-base: #f6f6f8;
--gate-glow-a: rgb(88 106 132 / 0.16); --gate-glow-a: rgb(84 102 130 / 0.22);
--gate-glow-b: rgb(126 140 160 / 0.12); --gate-glow-b: rgb(124 138 160 / 0.18);
--gate-card: #ffffff; --gate-card: #ffffff;
--gate-card-border: #e7e7e9; --gate-card-border: #e7e7e9;
--gate-callout-border: #d9d9dc; --gate-callout-bg: #f2f2f5;
--gate-callout-border: #cfcfd6;
--field: #f3f3f4; --field: #f3f3f4;
--field-border: #e2e2e4; --field-border: #e2e2e4;
--shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.3), 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);
@ -134,12 +135,13 @@
--sidebar-bottom: #151515; --sidebar-bottom: #151515;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75); --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. */ /* API-key gate: near-black stage, monochrome glows, raised input. */
--gate-base: #0c0c0d; --gate-base: #0f0f0f;
--gate-glow-a: rgb(255 255 255 / 0.14); --gate-glow-a: rgb(255 255 255 / 0.17);
--gate-glow-b: rgb(255 255 255 / 0.09); --gate-glow-b: rgb(255 255 255 / 0.12);
--gate-card: #171718; --gate-card: #1a1a1a;
--gate-card-border: rgb(255 255 255 / 0.08); --gate-card-border: rgb(255 255 255 / 0.08);
--gate-callout-border: rgb(255 255 255 / 0.2); --gate-callout-bg: rgb(255 255 255 / 0.07);
--gate-callout-border: rgb(255 255 255 / 0.22);
--field: #242425; --field: #242425;
--field-border: rgb(255 255 255 / 0.1); --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-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4);
@ -192,14 +194,33 @@ body {
background: transparent; background: transparent;
} }
/* API-key gate stage: monochrome radial glows in opposite corners over a /* API-key gate stage: two big blurred blobs (top-right + bottom-left) over a
near-black (dark) or near-white (light) base. Radial gradients are the near-black (dark) or near-white (light) base. Real blurred elements read as
softest "blurred glow" available without extra layers. */ atmosphere; a flat radial gradient was too faint. Both blobs are decorative,
non-interactive and sit behind the card (z-index 0 vs the card's z-10). */
.gate-bg { .gate-bg {
background: background: var(--gate-base);
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%), .gate-bg::before,
var(--gate-base); .gate-bg::after {
content: "";
position: absolute;
z-index: 0;
width: 48rem;
height: 48rem;
border-radius: 9999px;
filter: blur(120px);
pointer-events: none;
}
.gate-bg::before {
top: -20rem;
right: -16rem;
background: var(--gate-glow-a);
}
.gate-bg::after {
bottom: -20rem;
left: -16rem;
background: var(--gate-glow-b);
} }
.app-backdrop { .app-backdrop {

View file

@ -69,7 +69,7 @@ function CornerButton({
onClick={onClick} onClick={onClick}
aria-label={label} aria-label={label}
title={title} 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)]" className="grid size-10 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} {children}
</button> </button>
@ -123,8 +123,8 @@ function GateScreen() {
<div <div
className="fixed z-20 flex items-center gap-2" className="fixed z-20 flex items-center gap-2"
style={{ style={{
top: "max(env(safe-area-inset-top), 1rem)", top: "max(env(safe-area-inset-top), 1.25rem)",
right: "max(env(safe-area-inset-right), 1rem)", right: "max(env(safe-area-inset-right), 1.25rem)",
}} }}
> >
<CornerButton <CornerButton
@ -153,12 +153,12 @@ function GateScreen() {
</div> </div>
{/* card ----------------------------------------------------------- */} {/* card ----------------------------------------------------------- */}
<div className="relative z-10 flex min-h-dvh items-center justify-center px-5 py-10"> <div className="relative z-10 flex min-h-[100dvh] items-center justify-center px-5 py-10">
<motion.div <motion.div
initial={reduceMotion ? false : { opacity: 0, y: 14 }} initial={reduceMotion ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
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" className="my-auto w-full max-w-[420px] rounded-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 px-7 text-center shadow-[var(--shadow-gate)] sm:p-8"
> >
<span <span
aria-hidden aria-hidden
@ -167,11 +167,11 @@ function GateScreen() {
<SnowflakeIcon className="size-7" /> <SnowflakeIcon className="size-7" />
</span> </span>
<h1 className="mt-6 text-[26px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]"> <h1 className="mt-6 text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key Connect your Ollama key
</h1> </h1>
<p className="mx-auto mt-1.5 max-w-[300px] text-[13.5px] leading-relaxed text-[var(--text-subtle)]"> <p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
One step and you&rsquo;re ready to freeze your first deck. One step to freeze your first deck.
</p> </p>
<form className="mt-6" onSubmit={submit} noValidate> <form className="mt-6" onSubmit={submit} noValidate>
@ -223,20 +223,13 @@ function GateScreen() {
</p> </p>
) : null} ) : 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 <button
type="submit" type="submit"
disabled={!draft.trim() || busy} 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:bg-[rgb(13_13_13_/_0.45)] dark:disabled:bg-[rgb(255_255_255_/_0.45)]" className={cn(
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",
!draft.trim() && "cursor-not-allowed opacity-45",
)}
> >
{busy ? ( {busy ? (
<> <>
@ -248,6 +241,17 @@ function GateScreen() {
</button> </button>
</form> </form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => setHelpOpen(true)}
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<QuestionIcon className="size-3.5" />
How do I get a key?
</button>
</div>
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]"> <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. Your key stays in this browser. Our server never stores it.
</p> </p>
@ -285,10 +289,8 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }} exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }} transition={{ type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
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" className="thin-scroll max-h-[90dvh] w-full max-w-[480px] 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-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h2 <h2
id="gate-help-title" id="gate-help-title"
@ -306,7 +308,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
</button> </button>
</div> </div>
<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"> <div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" /> <CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]"> <p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
You only need the{" "} You only need the{" "}
@ -375,7 +377,7 @@ function Step({ index, children }: { index: number; children: React.ReactNode })
> >
{index} {index}
</span> </span>
<span className="text-[13px] leading-6 text-[var(--text-muted)]">{children}</span> <span className="text-pretty text-[13px] leading-6 text-[var(--text-muted)]">{children}</span>
</li> </li>
); );
} }