diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 045be5d0..ad7a3afa 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -2,6 +2,7 @@ import type { Hyperdrive } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; import { CodeProvider } from '@nestri/auth/provider/code'; import { CodeUI } from '@nestri/auth/ui/code'; +import type { Theme } from '@nestri/auth/ui/theme'; import { isDomainMatch } from '@nestri/auth/util'; import { Actor } from '@nestri/core/actor'; import { PostgresCodeStore } from '@nestri/core/auth/authorization-code'; @@ -169,11 +170,38 @@ export const allowClient = async ( return isDomainMatch(redirect, host); }; +/** + * The sign-in screen's theme. + * + * Almost everything that used to live here is now stated in + * `packages/auth/src/ui/css.ts`, which states the product's design language + * longhand. What is left here is the handful of values the + * issuer itself needs — and `primary`, which is the one colour the stylesheet + * reads back from the theme so the brand has a single source. + * + * There is no `background` and no light variant on purpose: the page is dark + * only, and the derived-colour scheme that made two schemes possible is + * exactly what rendered the sign-in field's text the colour of its own + * background. + */ +const THEME_NESTRI: Theme = { + title: 'Login | Nestri', + primary: 'hsl(12 84% 53%)', + favicon: 'https://nestri.io/images/favicon.ico', + // Mona Sans for the display line and the action, Geist for everything a + // person reads or types. Served from the Fontsource CDN because the + // self-hosted font packages need a bundler and nothing preprocesses this + // page — it is assembled as a string at request time. The family names must + // match the ones the stylesheet asks for. + css: `@import url('https://cdn.jsdelivr.net/fontsource/css/mona-sans:vf@latest/wght.css');@import url('https://cdn.jsdelivr.net/fontsource/css/geist:vf@latest/wght.css');` +}; + export default { async fetch(request: Request, env: Env, ctx?: ExecutionContext) { Env.init(env as unknown as Record); const inner = issuer({ subjects, + theme: THEME_NESTRI, // One database behind all of it, and nothing that only exists on // one hosting provider. What is left in the generic store is the // rate-limit counters — the only records here that are allowed to @@ -221,7 +249,6 @@ export default { // nothing — and a mistyped address that silently succeeds // leaves someone waiting for mail that went nowhere. ...CodeUI({ - copy: { code_info: "We'll email you a code to sign in." }, sendCode: async () => {} }), sendCode: async (claims, code) => { diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index d61424f6..2b312d15 100644 --- a/packages/auth/src/ui/base.tsx +++ b/packages/auth/src/ui/base.tsx @@ -1,15 +1,30 @@ +/** @jsxImportSource hono/jsx */ + import { PropsWithChildren } from 'hono/jsx'; +import css from './css.js'; import { getTheme } from './theme.js'; -import css from './css.js'; - +/** + * The page every sign-in screen is drawn inside. + * + * Two dashed bands across the top and bottom, closing into a 1440px-wide box + * on a wide screen; between them a 48-column field with a dashed vertical on + * the fifth gridline from each edge; and centred in it the lockup — wordmark, + * one line of copy, then whatever the provider is asking for. + * + * Dark only. There is no light variant to get wrong, which is the point: + * the previous version derived its colours from the background so that one + * theme could serve both, and that derivation is what left the input's text + * the same colour as the input's background. + */ export function Layout( props: PropsWithChildren<{ size?: 'small'; }> ) { const theme = getTheme(); + function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') { if (!theme) return; if (!theme[key]) return; @@ -18,69 +33,59 @@ export function Layout( return theme[key][mode] as string | undefined; } - const radius = (() => { - if (theme?.radius === 'none') return '0'; - if (theme?.radius === 'sm') return '1'; - if (theme?.radius === 'md') return '1.25'; - if (theme?.radius === 'lg') return '1.5'; - if (theme?.radius === 'full') return '1000000000001'; - return '1'; - })(); - - const hasLogo = get('logo', 'light') && get('logo', 'dark'); + // The one value the theme still drives. Everything else is stated in the + // stylesheet, because a second place to set a colour is a second place for + // it to be wrong. + const brand = get('primary', 'dark') ?? get('primary', 'light'); return ( - + - {theme?.title || 'OpenAuthJS'} + {theme?.title || 'Nestri'} - {theme?.favicon ? ( - - ) : ( - <> - - - - - - )} + + + {theme?.favicon && }