Frutiger Aero (Settings -> Theme): a third theme, not a dark/light variant. A sky gradient with a strip of grass, the glacier blobs become clouds, the orbs soap bubbles, glass gets the classic horizon gloss and the buttons a glossy aqua. The light/dark toggles (header, rail, chat header) hide while it is on and the picker says why; the pre-paint script applies the class before first paint and the PWA status bar follows (#3fa3e0). Its muted/frost text is darker than Light's because those labels sit directly on the sky. Contrast, measured on real pixels (tests/contrast.mjs, now covering aero): - The light settings sheet was translucent over a dark scrim, so its muted text measured ~2.8:1. Sheets and dialogs now use --glass-sheet-bg (a more opaque surface), and Light's --text-muted is a touch darker. - Result: 0 below-minimum findings and 0 axe violations across every screen, all three themes, phone and desktop. Also: the suites now seed the cookie notice as already dismissed (they run as a returning user); the regression suite opts out to test the notice itself — the previous deploy failed its gate because the notice blocked a chat chip click. The notice also hides during immersive sessions (overlay-open), so it can never cover a study feed. Version 0.1.8, SW -> freezmo-v54.
473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
|
import { useTheme } from "@/components/ThemeToggle";
|
|
import {
|
|
CheckCircleIcon,
|
|
ExternalLinkIcon,
|
|
EyeIcon,
|
|
EyeOffIcon,
|
|
MoonIcon,
|
|
QuestionIcon,
|
|
SunIcon,
|
|
XIcon,
|
|
} from "@/components/icons";
|
|
import { Spinner } from "@/components/ui";
|
|
import { LOCALES, useI18n } from "@/lib/i18n";
|
|
import { verifyOllamaKey } from "@/lib/client-api";
|
|
import { saveSettings, settingsStore } from "@/lib/settings";
|
|
import { useHealth } from "@/lib/use-health";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const KEYS_URL = "https://ollama.com/settings/keys";
|
|
|
|
type GateError = { tone: "invalid" | "offline"; message: string } | null;
|
|
|
|
/**
|
|
* Full-screen gate shown when this browser has no Ollama API key yet (and the
|
|
* server runs in `user-key` mode). It replaces the app shell entirely: no
|
|
* sidebar, no header. The key is validated with a real one-token probe before
|
|
* it is saved, and it is still stored only in this browser.
|
|
*/
|
|
export function ApiKeyGate({ children }: { children: React.ReactNode }) {
|
|
const storedKey = useSyncExternalStore(
|
|
settingsStore.subscribe,
|
|
() => settingsStore.getSnapshot().apiKey,
|
|
() => "",
|
|
);
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
const { health } = useHealth();
|
|
|
|
// Server-key and local-daemon installs never need a browser key.
|
|
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
|
|
|
|
// Until hydration finishes, render the page as usual so a key holder never
|
|
// sees the gate flash. After that, a missing key replaces the app.
|
|
if (!mounted || storedKey || !needsKeyMode) return <>{children}</>;
|
|
|
|
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-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}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function GateScreen() {
|
|
const reduceMotion = useReducedMotion();
|
|
const { theme, toggle } = useTheme();
|
|
const { locale, setLocale, t } = useI18n();
|
|
const [draft, setDraft] = useState("");
|
|
const [reveal, setReveal] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<GateError>(null);
|
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const submitRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
// Don't force the on-screen keyboard open on touch devices — let the user
|
|
// tap the field when they're ready.
|
|
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
|
|
}, []);
|
|
|
|
// Keep the field and the Continue button in view while the on-screen keyboard
|
|
// is up (the card itself is locked, so this is done by scrolling the wrapper
|
|
// programmatically rather than by the user).
|
|
const alignKeyboard = useCallback(() => {
|
|
const wrap = wrapRef.current;
|
|
const submit = submitRef.current;
|
|
if (!wrap || !submit) return;
|
|
const wrapBox = wrap.getBoundingClientRect();
|
|
const btnBox = submit.getBoundingClientRect();
|
|
const below = btnBox.bottom + 12 - wrapBox.bottom;
|
|
const above = wrapBox.top + 12 - btnBox.top;
|
|
if (below > 0) wrap.scrollTop += below;
|
|
else if (above > 0) wrap.scrollTop -= above;
|
|
}, []);
|
|
|
|
// Pin the gate to the *visual* viewport so the browser never has to pan the
|
|
// page to reveal the focused field. While the on-screen keyboard is up the
|
|
// card is locked (not user-scrollable).
|
|
useEffect(() => {
|
|
const vv = window.visualViewport;
|
|
const root = rootRef.current;
|
|
if (!vv || !root) return;
|
|
|
|
const baseline = window.innerHeight;
|
|
let frame = 0;
|
|
|
|
const sync = () => {
|
|
if (frame) cancelAnimationFrame(frame);
|
|
frame = requestAnimationFrame(() => {
|
|
root.style.height = `${vv.height}px`;
|
|
root.style.top = `${vv.offsetTop}px`;
|
|
|
|
const open = baseline - vv.height > 120;
|
|
setKeyboardOpen(open);
|
|
if (open) alignKeyboard();
|
|
});
|
|
};
|
|
|
|
sync();
|
|
vv.addEventListener("resize", sync);
|
|
vv.addEventListener("scroll", sync);
|
|
return () => {
|
|
if (frame) cancelAnimationFrame(frame);
|
|
vv.removeEventListener("resize", sync);
|
|
vv.removeEventListener("scroll", sync);
|
|
};
|
|
}, [alignKeyboard]);
|
|
|
|
// Re-align when the card grows/shrinks (e.g. an error message appears) while
|
|
// the keyboard is open.
|
|
useEffect(() => {
|
|
if (!keyboardOpen) return;
|
|
const frame = requestAnimationFrame(alignKeyboard);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [keyboardOpen, error, busy, alignKeyboard]);
|
|
|
|
useEffect(() => {
|
|
if (!helpOpen) return;
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setHelpOpen(false);
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [helpOpen]);
|
|
|
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const key = draft.trim();
|
|
if (!key || busy) return;
|
|
|
|
setBusy(true);
|
|
setError(null);
|
|
const check = await verifyOllamaKey(key);
|
|
|
|
if (check.result === "valid") {
|
|
// Saving flips the gate off and drops the user into the library.
|
|
saveSettings({ apiKey: key });
|
|
return;
|
|
}
|
|
setError({ tone: check.result === "invalid" ? "invalid" : "offline", message: check.message });
|
|
setBusy(false);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
className="fixed inset-0 h-dvh overflow-hidden overscroll-none"
|
|
>
|
|
{/* corner controls: theme + help ---------------------------------- */}
|
|
<div
|
|
className="absolute z-20 flex items-center gap-2"
|
|
style={{
|
|
top: "max(env(safe-area-inset-top), 1.25rem)",
|
|
right: "max(env(safe-area-inset-right), 1.25rem)",
|
|
}}
|
|
>
|
|
<CornerButton
|
|
label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
|
|
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
|
|
onClick={toggle}
|
|
>
|
|
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
|
|
</CornerButton>
|
|
|
|
<CornerButton
|
|
label={t("language.label")}
|
|
title={t("language.label")}
|
|
onClick={() => {
|
|
const index = LOCALES.findIndex((entry) => entry.id === locale);
|
|
setLocale(LOCALES[(index + 1) % LOCALES.length].id);
|
|
}}
|
|
>
|
|
<span className="text-[12px] font-bold tracking-wide">
|
|
{LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"}
|
|
</span>
|
|
</CornerButton>
|
|
|
|
<div className="group relative">
|
|
<CornerButton
|
|
label={t("gate.howGetAria")}
|
|
title={t("gate.howGet")}
|
|
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"
|
|
>
|
|
{t("gate.howGet")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* card ----------------------------------------------------------- */}
|
|
<div
|
|
ref={wrapRef}
|
|
className={cn(
|
|
"relative z-10 flex h-full justify-center px-5",
|
|
keyboardOpen
|
|
? "items-start touch-none overflow-hidden py-3"
|
|
: "items-center overflow-y-auto overscroll-contain 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="gate-card my-auto w-full max-w-[420px] p-6 px-7 text-center sm:p-8"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
|
|
<img
|
|
src="/icons/icon-192.png"
|
|
alt=""
|
|
aria-hidden
|
|
className="mx-auto size-14 rounded-[16px] shadow-[var(--shadow-card)]"
|
|
/>
|
|
|
|
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
|
|
{t("gate.title")}
|
|
</h1>
|
|
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("gate.subtitle")}
|
|
</p>
|
|
|
|
<form className="mt-6" onSubmit={submit} noValidate>
|
|
<div className="relative">
|
|
<label className="sr-only" htmlFor="gate-api-key">
|
|
{t("gate.apiKeyLabel")}
|
|
</label>
|
|
<input
|
|
ref={inputRef}
|
|
id="gate-api-key"
|
|
type={reveal ? "text" : "password"}
|
|
value={draft}
|
|
onChange={(event) => {
|
|
setDraft(event.target.value);
|
|
if (error) setError(null);
|
|
}}
|
|
placeholder={t("gate.placeholder")}
|
|
autoComplete="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
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-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"
|
|
onClick={() => setReveal((value) => !value)}
|
|
aria-label={reveal ? t("gate.hide") : t("gate.show")}
|
|
aria-pressed={reveal}
|
|
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
{reveal ? <EyeOffIcon className="size-[18px]" /> : <EyeIcon className="size-[18px]" />}
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p
|
|
id="gate-api-key-error"
|
|
role="alert"
|
|
className={cn(
|
|
"mt-2.5 text-[12.5px] leading-relaxed",
|
|
error.tone === "invalid"
|
|
? "text-rose-700 dark:text-rose-300"
|
|
: "text-amber-700 dark:text-amber-300",
|
|
)}
|
|
>
|
|
{error.message}
|
|
</p>
|
|
) : null}
|
|
|
|
<button
|
|
type="submit"
|
|
ref={submitRef}
|
|
disabled={!draft.trim() || busy}
|
|
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 ? (
|
|
<>
|
|
<Spinner className="size-4" /> {t("gate.checking")}
|
|
</>
|
|
) : (
|
|
t("gate.continue")
|
|
)}
|
|
</button>
|
|
</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" />
|
|
{t("gate.howGet")}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("gate.stays")}
|
|
</p>
|
|
</motion.div>
|
|
</div>
|
|
|
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* “How do I get a key?” */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const reduceMotion = useReducedMotion();
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<motion.div
|
|
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center sm:p-6"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="gate-help-title"
|
|
initial={reduceMotion ? false : { y: 40, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
|
|
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="gate-card glass-sheet thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
|
|
>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2
|
|
id="gate-help-title"
|
|
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
|
|
>
|
|
{t("gate.helpTitle")}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("common.close")}
|
|
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>
|
|
|
|
<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)]" />
|
|
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
|
{t("gate.freeTier.before")}{" "}
|
|
<strong className="font-semibold text-[var(--text)]">{t("gate.freeTier.strong")}</strong>
|
|
{t("gate.freeTier.after")}
|
|
</p>
|
|
</div>
|
|
|
|
<ol className="mt-5 space-y-4">
|
|
<Step index={1}>
|
|
{t("gate.step1.before")}{" "}
|
|
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink>{" "}
|
|
{t("gate.step1.after")}
|
|
</Step>
|
|
<Step index={2}>
|
|
{t("gate.step2.before")}{" "}
|
|
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>
|
|
{t("gate.step2.after")}
|
|
</Step>
|
|
<Step index={3}>
|
|
{t("gate.step3.before")}{" "}
|
|
<span className="font-medium text-[var(--text-muted)]">{t("gate.step3.strong")}</span>
|
|
{t("gate.step3.after")}
|
|
</Step>
|
|
<Step index={4}>{t("gate.step4")}</Step>
|
|
</ol>
|
|
|
|
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("gate.footnote")}
|
|
</p>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
|
|
>
|
|
{t("gate.gotIt")}
|
|
</button>
|
|
</motion.div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
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 items-start gap-3">
|
|
<span
|
|
aria-hidden
|
|
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-pretty text-[13px] leading-6 text-[var(--text-muted)]">{children}</span>
|
|
</li>
|
|
);
|
|
}
|