diff --git a/app/globals.css b/app/globals.css index 1566715..08736b5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -113,6 +113,11 @@ --field: #f3f3f4; --field-border: #e2e2e4; --shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.3), 0 1px 2px rgb(13 13 13 / 0.05); + /* Install sheet: light variant (dark is the default, see .dark). */ + --install-sheet: #ffffff; + --install-card: #f7f7f8; + --install-chip: #f2f2f2; + --install-border: #e0e0e0; --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); } @@ -145,6 +150,11 @@ --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); + /* Install sheet: layered dark surfaces (default theme for the sheet). */ + --install-sheet: #1c1c1e; + --install-card: #2c2c2e; + --install-chip: #3a3a3c; + --install-border: rgb(255 255 255 / 0.08); --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); } diff --git a/components/InstallSheet.tsx b/components/InstallSheet.tsx index 2887f2c..6f370bd 100644 --- a/components/InstallSheet.tsx +++ b/components/InstallSheet.tsx @@ -5,11 +5,11 @@ import { useCallback, useEffect, useState } from "react"; import { AddToHomeIcon, + ChevronDownIcon, ChromeIcon, CompassIcon, DownloadIcon, EllipsisIcon, - MoreIcon, PlusIcon, ShareIcon, SnowflakeIcon, @@ -53,7 +53,7 @@ export function InstallSheet() { const [installing, setInstalling] = useState(false); const dragControls = useDragControls(); const reduceMotion = useReducedMotion(); - const domain = typeof window === "undefined" ? "" : window.location.host; + const domain = typeof window === "undefined" ? "" : window.location.hostname; /** Everything needed to render the sheet, or null if it shouldn't appear. */ const prepare = useCallback((): SheetInfo | null => { @@ -143,59 +143,61 @@ export function InstallSheet() { setInstalling(false); }; + // The exact iOS flow: Safari users skip the "open in Safari" step entirely. const steps: React.ReactNode[] = []; if (info?.platform === "ios") { if (info.browser !== "safari") { steps.push( <> - Open this page in }>Safari. + Open this page in }>Safari , ); } steps.push( <> - Tap }>Share in the browser bar — in - some browsers it’s under }>⋯. + Tap } label="More options" /> in the + browser bar , ); steps.push( <> - Tap }>More in the share sheet. + Tap }>Share, then{" "} + }>More , ); steps.push( <> - Choose }>Add to Home Screen. + Choose }>Add to Home Screen , ); steps.push( <> - Tap }>Add to confirm. + Tap }>Add to confirm , ); } else if (info?.platform === "android") { if (info.browser !== "chrome") { steps.push( <> - Open this page in }>Chrome. + Open this page in }>Chrome , ); } steps.push( <> - Tap }>⋯ in the top right of the - browser. + Tap } label="More options" /> in the top + right of the browser , ); steps.push( <> - Choose }>Install app or{" "} - }>Add to Home screen. + Choose }>Install app or{" "} + }>Add to Home screen , ); steps.push( <> - Tap }>Install to confirm. + Tap }>Install to confirm , ); } @@ -216,7 +218,7 @@ export function InstallSheet() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.25 }} - className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" + className="absolute inset-0 bg-black/50 backdrop-blur-[4px]" /> { @@ -234,15 +237,15 @@ export function InstallSheet() { animate={reduceMotion ? { opacity: 1 } : { y: 0 }} exit={reduceMotion ? { opacity: 0 } : { y: "100%" }} transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }} - className="absolute inset-x-0 bottom-0 mx-auto w-full rounded-t-[28px] border border-b-0 border-[var(--hairline)] bg-[var(--surface)] shadow-[0_-20px_60px_-28px_rgb(0_0_0/0.5)] sm:max-w-xl" + className="absolute inset-x-0 bottom-0 mx-auto w-full rounded-t-[28px] border border-b-0 border-[var(--install-border)] bg-[var(--install-sheet)] shadow-[0_-20px_60px_-28px_rgb(0_0_0/0.5)] sm:max-w-xl" > -
+
dragControls.start(event)} className="mx-auto flex h-6 w-full cursor-grab touch-none items-center justify-center active:cursor-grabbing" > - +
@@ -265,10 +268,10 @@ export function InstallSheet() {
{/* app card */} -
+
@@ -276,7 +279,7 @@ export function InstallSheet() { Freezmo - + {domain} @@ -302,22 +305,32 @@ export function InstallSheet() { ) : null} {showNativePrompt ? ( -

+

Or install manually:

) : null} -
    +
      {steps.map((step, index) => ( -
    1. - {index + 1}. - {step} +
    2. + + {index + 1} + + {step}
    3. ))}
    + +
@@ -330,17 +343,30 @@ export function InstallSheet() { /** Small pill for a UI element the user has to tap. */ function Chip({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return ( - + {icon} {children} ); } -/** The one coloured element: the final confirmation button. */ +/** Icon-only pill, for buttons that are just a glyph (the ⋯ menu). */ +function IconChip({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( + + {icon} + + ); +} + +/** The one coloured element: the final confirmation button (iOS blue). */ function AccentPill({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return ( - + {icon} {children} diff --git a/components/icons.tsx b/components/icons.tsx index cef1091..ccc9633 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -307,13 +307,10 @@ export function EllipsisIcon({ className }: IconProps) { ); } -export function MoreIcon({ className }: IconProps) { +export function ChevronDownIcon({ className }: IconProps) { return ( - - - - + ); } diff --git a/lib/client-api.ts b/lib/client-api.ts index 599f64b..d924771 100644 --- a/lib/client-api.ts +++ b/lib/client-api.ts @@ -200,12 +200,25 @@ export type Health = { }; export async function fetchHealth(): Promise { + let response: Response; try { - const response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() }); - return (await response.json()) as Health; + response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() }); } catch { throw new ApiError("Could not reach the Freezmo server.", "network"); } + const body = (await response.json().catch(() => null)) as (Partial & { error?: string; kind?: string }) | null; + if (!response.ok) { + throw new ApiError( + body?.error ?? `The health check failed with HTTP ${response.status}.`, + body?.kind ?? "http", + ); + } + // Guard against anything that isn't a health payload (e.g. a proxy error + // page) so consumers can rely on the shape. + if (!body || typeof body !== "object" || !body.vision || !body.text) { + throw new ApiError("The Freezmo server sent an unexpected health response.", "bad_response"); + } + return body as Health; } export type KeyCheck =