mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(auth): draw the sign-in screen in the product's design language
The screen was still the upstream template's: its font, its accent, its logo, and a theme that tried to serve a light and a dark scheme from one set of colours by deriving each one from the background's lightness. That derivation is replaced with stated values, and the page is dark only. Black, a neutral grey ramp, one brand accent, Mona Sans for the display line and Geist for anything read or typed; two dashed bands closing into a box on a wide screen, a dashed vertical either side of the column, the wordmark, and the line of copy the rest of the product opens with. The email field keeps the fix from the previous commit and takes the brand colour on focus, which is the only place it appears besides the wordmark. Measured against a render of the same design built from its own source: the band rules, the column rules, the wordmark box and the button height land on identical pixels.
This commit is contained in:
@@ -173,33 +173,27 @@ export const allowClient = async (
|
||||
/**
|
||||
* The sign-in screen's theme.
|
||||
*
|
||||
* Restored from the pre-fork issuer config, which carried these exact values
|
||||
* before the vendored `packages/auth` replaced it and nothing set a theme at
|
||||
* all — leaving every sign-in on `THEME_OPENAUTH`, which is somebody else's
|
||||
* brand and, with the input bug this shipped beside, an unreadable one.
|
||||
* 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.
|
||||
*
|
||||
* `primary` is the one value worth not changing casually: it is the brand
|
||||
* orange, and the button's own text colour is derived from its lightness
|
||||
* rather than stated, so a lighter primary silently flips that text to black.
|
||||
* 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: 'Nestri | Auth',
|
||||
primary: '#FF4F01',
|
||||
// Not the URLs the old config carried: `/logo.webp` and `/seo/favicon.ico`
|
||||
// both 404 today, and `base.tsx` falls back to OpenAuth's own mark only when
|
||||
// `logo` is absent — a broken URL renders a broken image instead. These two
|
||||
// are what the site actually serves.
|
||||
logo: 'https://nestri.io/images/android-chrome-512x512.png',
|
||||
title: 'Login | Nestri',
|
||||
primary: 'hsl(12 84% 53%)',
|
||||
favicon: 'https://nestri.io/images/favicon.ico',
|
||||
background: {
|
||||
light: '#f5f5f5',
|
||||
dark: '#171717'
|
||||
},
|
||||
radius: 'lg',
|
||||
font: {
|
||||
family: 'Geist, sans-serif'
|
||||
},
|
||||
css: `@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');`
|
||||
// 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 this page is a string
|
||||
// rendered in a Worker with no build step. 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 {
|
||||
@@ -255,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) => {
|
||||
|
||||
@@ -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 (
|
||||
<html
|
||||
style={{
|
||||
'--color-background-light': get('background', 'light'),
|
||||
'--color-background-dark': get('background', 'dark'),
|
||||
'--color-primary-light': get('primary', 'light'),
|
||||
'--color-primary-dark': get('primary', 'dark'),
|
||||
'--font-family': theme?.font?.family,
|
||||
'--font-scale': theme?.font?.scale,
|
||||
'--border-radius': radius
|
||||
}}>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{theme?.title || 'OpenAuthJS'}</title>
|
||||
<title>{theme?.title || 'Nestri'}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{theme?.favicon ? (
|
||||
<link rel="icon" href={theme?.favicon} />
|
||||
) : (
|
||||
<>
|
||||
<link rel="icon" href="https://openauth.js.org/favicon.ico" sizes="48x48" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://openauth.js.org/favicon.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://openauth.js.org/favicon-dark.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="https://openauth.js.org/favicon.svg"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="hsl(0 0% 0%)" />
|
||||
{theme?.favicon && <link rel="icon" href={theme.favicon} />}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{brand && <style dangerouslySetInnerHTML={{ __html: `:root{--color-brand:${brand}}` }} />}
|
||||
{theme?.css && <style dangerouslySetInnerHTML={{ __html: theme.css }} />}
|
||||
</head>
|
||||
<body>
|
||||
<div data-component="root">
|
||||
<div data-component="page">
|
||||
<div data-component="frame">
|
||||
<div data-component="band" data-edge="top">
|
||||
<div />
|
||||
</div>
|
||||
<div data-component="main">
|
||||
<div data-component="field">
|
||||
<Rule />
|
||||
<Rule side="end" />
|
||||
<div data-component="center" data-size={props.size}>
|
||||
{hasLogo ? (
|
||||
<>
|
||||
<img data-component="logo" src={get('logo', 'light')} data-mode="light" />
|
||||
<img data-component="logo" src={get('logo', 'dark')} data-mode="dark" />
|
||||
</>
|
||||
) : (
|
||||
ICON_OPENAUTH
|
||||
)}
|
||||
{props.children}
|
||||
<div data-component="stack">
|
||||
<div data-component="logo">
|
||||
<LogoWord />
|
||||
</div>
|
||||
<h2 data-component="title">
|
||||
One place for all the ways you play.{' '}
|
||||
<strong>
|
||||
Gather Around
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank">
|
||||
.
|
||||
</a>
|
||||
</strong>
|
||||
</h2>
|
||||
<div data-component="actions">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-component="band" data-edge="bottom">
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -88,17 +93,83 @@ export function Layout(
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_OPENAUTH = (
|
||||
<svg
|
||||
data-component="logo-default"
|
||||
width="51"
|
||||
height="51"
|
||||
viewBox="0 0 51 51"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 50.2303V0.12854H50.1017V50.2303H0ZM3.08002 11.8326H11.7041V3.20856H3.08002V11.8326ZM14.8526 11.8326H23.4766V3.20856H14.8526V11.8326ZM26.5566 11.8326H35.1807V3.20856H26.5566V11.8326ZM38.3292 11.8326H47.0217V3.20856H38.3292V11.8326ZM3.08002 23.6052H11.7041V14.9811H3.08002V23.6052ZM14.8526 23.6052H23.4766V14.9811H14.8526V23.6052ZM26.5566 23.6052H35.1807V14.9811H26.5566V23.6052ZM38.3292 23.6052H47.0217V14.9811H38.3292V23.6052ZM3.08002 35.3092H11.7041V26.6852H3.08002V35.3092ZM14.8526 35.3092H23.4766V26.6852H14.8526V35.3092ZM26.5566 35.3092H35.1807V26.6852H26.5566V35.3092ZM38.3292 35.3092H47.0217V26.6852H38.3292V35.3092ZM3.08002 47.1502H11.7041V38.3893H3.08002V47.1502ZM14.8526 47.1502H23.4766V38.3893H14.8526V47.1502ZM26.5566 47.1502H35.1807V38.3893H26.5566V47.1502ZM38.3292 47.1502H47.0217V38.3893H38.3292V47.1502Z"
|
||||
fill="currentColor"
|
||||
/**
|
||||
* One of the two dashed verticals the column sits between.
|
||||
*
|
||||
* Drawn as an SVG line rather than a border because the dash pattern has to
|
||||
* match the horizontal bands' `border-style: dashed`, and a border cannot be
|
||||
* given a round cap.
|
||||
*/
|
||||
function Rule(props: { side?: 'end' }) {
|
||||
return (
|
||||
<svg data-component="rule" data-side={props.side} width="2" height="100%" aria-hidden="true">
|
||||
<line
|
||||
x1="0.5"
|
||||
y1="0"
|
||||
x2="0.5"
|
||||
y2="100%"
|
||||
stroke-width="1"
|
||||
stroke="currentColor"
|
||||
stroke-dasharray="4 4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The NESTRI wordmark.
|
||||
*
|
||||
* `fill="currentColor"` throughout, so the colour comes from the brand token
|
||||
* on its container rather than being baked into the paths.
|
||||
*/
|
||||
function LogoWord() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 31.749999 6.3499999"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Nestri">
|
||||
<g stroke="none" stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
|
||||
<g transform="matrix(1.0384818,0,0,1.0384818,-0.26119451,0.00780597)">
|
||||
<path
|
||||
d="m 93.240234,43.240234 v 3.34961 l -0.40039,-0.357422 c -2.096687,-1.875287 -4.792416,-2.933207 -7.59961,-2.990235 v 3.572266 c 4.331915,0.124459 7.820855,3.613398 7.945313,7.945313 h 3.574219 V 43.240234 Z"
|
||||
fill="currentColor"
|
||||
transform="matrix(0.44279029,0,0,0.44279029,-36.976573,-18.649472)"
|
||||
/>
|
||||
<path
|
||||
d="m 85.240234,47.292969 v 3.642578 c 2.057061,0.11945 3.704769,1.767158 3.824219,3.824219 h 3.642578 C 92.583101,50.689065 89.310935,47.4169 85.240234,47.292969 Z"
|
||||
fill="currentColor"
|
||||
transform="matrix(0.44279029,0,0,0.44279029,-36.976573,-18.649472)"
|
||||
/>
|
||||
<path
|
||||
d="m 85.240234,51.416016 v 3.34375 h 3.34375 c -0.117719,-1.795413 -1.548337,-3.226031 -3.34375,-3.34375 z"
|
||||
fill="currentColor"
|
||||
transform="matrix(0.44279029,0,0,0.44279029,-36.976573,-18.649472)"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="m 6.3553899,0.52902957 v 0.1682512 1.53946343 h 1.6989362 0.00878 1.7077191 V 2.4050039 H 8.064776 a 1.7064502,1.7064502 0 0 0 -4.35e-4,0 1.7064502,1.7064502 0 0 0 -0.00131,0 v 0.011399 a 1.7109739,1.7109739 0 0 1 -0.0013,-0.011399 H 6.3551932 v 1.5394593 0.1684904 h 0.00261 A 1.7064502,1.7064502 0 0 0 7.3305504,5.6521827 1.7064502,1.7064502 0 0 0 8.0629013,5.818063 v 0.00263 H 9.7706262 11.478346 V 5.6521776 4.1129494 H 9.7706244 8.0629097 v -0.168495 h 1.5280697 0.1715786 0.00802 0.1682601 a 1.7109739,1.7109739 0 0 0 1.5292529,-1.539459 1.7109739,1.7109739 0 0 0 0.0079,-0.1625607 1.7109739,1.7109739 0 0 0 0,-0.00569 1.7109739,1.7109739 0 0 0 -0.976066,-1.53946152 1.7109739,1.7109739 0 0 0 -0.7292697,-0.1658802 v -0.00217 H 8.0628921 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="m 13.709845,0.52614613 v 0.002175 a 1.7119553,1.7119553 0 0 0 -0.729689,0.16598235 1.7119553,1.7119553 0 0 0 -0.976632,1.54034282 1.7119553,1.7119553 0 0 0 0,0.00569 1.7119553,1.7119553 0 0 0 0.0079,0.162659 1.7119553,1.7119553 0 0 0 1.530129,1.5403426 h 0.168355 0.0081 0.171679 1.528947 v 0.168587 H 13.709919 12.001222 V 5.6520324 5.820626 h 1.708697 1.708701 v -0.00263 a 1.7074292,1.7074292 0 0 0 0.732769,-0.1659739 1.7074292,1.7074292 0 0 0 0.973313,-1.5401077 1.7074292,1.7074292 0 0 0 0,-0.00131 1.7074292,1.7074292 0 0 0 -0.0081,-0.167164 1.7074292,1.7074292 0 0 0 -1.696823,-1.5403427 1.7119553,1.7119553 0 0 1 -0.0013,0.011382 v -0.011382 a 1.7074292,1.7074292 0 0 0 -0.0013,0 h -4.35e-4 -1.707038 v -0.168328 h 1.708702 0.0088 1.69991 V 0.69442656 0.52607662 h -1.708693 z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M 17.649994,0.52916663 V 2.1732606 h 1.814408 0.0095 v 0.1796921 0.012166 1.6319287 0.1799455 1.6438406 h 0.784918 1.038868 V 4.1769923 a 1.8224328,1.8224328 0 0 0 0,-0.00151 1.8224328,1.8224328 0 0 0 -0.0079,-0.1687946 1.8224328,1.8224328 0 0 0 -7.34e-4,-0.00964 h 0.0085 V 2.3529527 2.1732606 h 1.821252 0.0026 V 0.52916663 h -1.044966 -0.778835 -0.791 -1.032787 z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M 23.644282,0.52916392 V 0.69714688 2.2341075 h 1.696176 0.0087 1.704944 v 0.1679826 h -1.551414 -0.152345 c -4.4e-4,0 -7.34e-4,0 -0.0012,0 v 0.012561 c -4.25e-4,-0.00412 -7.79e-4,-0.00837 -0.0012,-0.012561 -0.472042,6.912e-4 -0.899231,0.1934204 -1.207377,0.5041854 -0.268576,0.270858 -0.446712,0.6314329 -0.485705,1.0327745 -0.0054,0.054867 -0.008,0.1105228 -0.008,0.1667991 0,4.401e-4 -2e-6,9.809e-4 0,0.00145 h -0.0025 v 1.536728 0.1682203 h 1.704942 V 5.8095798 5.6439665 4.1697918 l 1.14579,1.1981514 0.264176,0.2760233 0.160875,0.168219 h 0.134103 1.704942 0.153767 l -0.153703,-0.1537667 -0.01446,-0.014453 -1.536723,-1.536723 -0.153767,-0.153768 -0.0055,-0.00546 c 0.0018,0 0.0037,-2.316e-4 0.0055,-2.364e-4 0.05714,-1.855e-4 0.113724,-0.00315 0.169404,-0.00873 0.807873,-0.082186 1.449216,-0.7273834 1.525589,-1.5369611 0.0051,-0.053473 0.0076,-0.1075037 0.0076,-0.1622959 0,-0.00194 7e-6,-0.00376 0,-0.0057 -0.0024,-0.6779645 -0.399622,-1.26300854 -0.973719,-1.53694086 -0.220934,-0.10543433 -0.468068,-0.1647717 -0.729009,-0.1656172 v -0.002364 h -1.704942 z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="m 29.435832,0.5290966 v 1.4644007 0.179692 0.1796919 0.01217 1.6319272 0.1799453 1.6438394 h 0.784916 1.038868 v -1.643844 a 1.8224314,1.8224314 0 0 0 0,-0.00153 1.8224314,1.8224314 0 0 0 -0.0077,-0.1687941 1.8224314,1.8224314 0 0 0 -7.87e-4,-0.00964 h 0.0085 V 2.3528812 2.1731893 0.5290966 H 30.46863 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,20 @@ const DEFAULT_COPY = {
|
||||
* guesses made. Deliberately one message for both: which of the two it was
|
||||
* is a fact about somebody else's mailbox.
|
||||
*/
|
||||
rate_limited: 'Too many attempts. Wait a moment and start again.'
|
||||
rate_limited: 'Too many attempts. Wait a moment and start again.',
|
||||
/**
|
||||
* The consent line under the action, split around its two links so the
|
||||
* sentence stays one translatable run rather than being glued together
|
||||
* from fragments in the markup.
|
||||
*/
|
||||
terms_before:
|
||||
'By continuing, you acknowledge that you have read and understood, and agree to Nestri\u2019s ',
|
||||
terms_label: 'Terms & Conditions',
|
||||
terms_url: 'https://nestri.io/terms',
|
||||
terms_between: ' and ',
|
||||
privacy_label: 'Privacy Policy',
|
||||
privacy_url: 'https://nestri.io/privacy',
|
||||
terms_after: '.'
|
||||
};
|
||||
|
||||
export type CodeUICopy = typeof DEFAULT_COPY;
|
||||
@@ -143,7 +156,17 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
</form>
|
||||
<p data-component="form-footer">{copy.code_info}</p>
|
||||
<p data-component="form-footer">
|
||||
{copy.terms_before}
|
||||
<a href={copy.terms_url} rel="noopener noreferrer" target="_blank">
|
||||
{copy.terms_label}
|
||||
</a>
|
||||
{copy.terms_between}
|
||||
<a href={copy.privacy_url} rel="noopener noreferrer" target="_blank">
|
||||
{copy.privacy_label}
|
||||
</a>
|
||||
{copy.terms_after}
|
||||
</p>
|
||||
</Layout>
|
||||
);
|
||||
return new Response(jsx.toString(), {
|
||||
|
||||
@@ -1,270 +1,433 @@
|
||||
export default `:root {
|
||||
color-scheme: light dark;
|
||||
/**
|
||||
* The sign-in screen's stylesheet.
|
||||
*
|
||||
* The design language is the product's own: a neutral grey ramp on black, one
|
||||
* brand accent, Mona Sans for display and Geist for everything read or typed,
|
||||
* and a page framed by dashed rules. It is expressed as utility classes
|
||||
* wherever it is built with a CSS framework; this page is a string rendered in
|
||||
* a Worker with no build step, so the same values are written out longhand
|
||||
* here. They are stated as literals on purpose — a token that is computed in
|
||||
* one place and copied in another drifts without anyone seeing it.
|
||||
*
|
||||
* **Dark only, deliberately.** The upstream stylesheet derived every colour
|
||||
* from the background's lightness through `oklch(from ...)` so one theme could
|
||||
* serve both schemes. That is what made the sign-in field black-on-black, and
|
||||
* the product has one scheme anyway. Colours here are stated, not derived.
|
||||
*/
|
||||
export default `
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-background-dark: #0e0e11;
|
||||
--color-background-light: #ffffff;
|
||||
--color-primary-dark: #6772e5;
|
||||
--color-primary-light: #6772e5;
|
||||
--color-gray-100: hsl(0 0% 10%);
|
||||
--color-gray-200: hsl(0 0% 12%);
|
||||
--color-gray-300: hsl(0 0% 16%);
|
||||
--color-gray-400: hsl(0 0% 18%);
|
||||
--color-gray-500: hsl(0 0% 27%);
|
||||
--color-gray-600: hsl(0 0% 53%);
|
||||
--color-gray-800: hsl(0 0% 49%);
|
||||
--color-gray-900: hsl(0 0% 63%);
|
||||
--color-gray-1000: hsl(0 0% 93%);
|
||||
|
||||
--color-background-success-dark: oklch(0.3 0.04 172);
|
||||
--color-background-success-light: oklch(from var(--color-background-success-dark) 0.83 c h);
|
||||
--color-success-dark: oklch(from var(--color-background-success-dark) 0.92 c h);
|
||||
--color-success-light: oklch(from var(--color-background-success-dark) 0.25 c h);
|
||||
--color-background-100: hsl(0 0% 4%);
|
||||
--color-background-200: hsl(0 0% 0%);
|
||||
|
||||
--color-background-error-dark: oklch(0.32 0.07 15);
|
||||
--color-background-error-light: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||
--color-error-dark: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||
--color-error-light: oklch(from var(--color-background-error-dark) 0.25 c h);
|
||||
/* Overridden per-request from the theme's \`primary\`, so the brand colour
|
||||
has one source and this is only the fallback. */
|
||||
--color-brand: hsl(12 84% 53%);
|
||||
--color-border: var(--color-gray-200);
|
||||
--color-foreground: var(--color-gray-1000);
|
||||
--color-muted-foreground: var(--color-gray-900);
|
||||
--color-muted-foreground2: var(--color-gray-800);
|
||||
|
||||
--border-radius: 0;
|
||||
--color-red-600: hsl(358 75% 59%);
|
||||
--color-red-100: hsl(357 37% 12%);
|
||||
--color-green-600: hsl(151 55% 42%);
|
||||
--color-green-100: hsl(154 49% 9%);
|
||||
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-primary: var(--color-primary-dark);
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mona: 'Mona Sans Variable', var(--font-sans);
|
||||
|
||||
--color-background-success: var(--color-background-success-dark);
|
||||
--color-success: var(--color-success-dark);
|
||||
--color-background-error: var(--color-background-error-dark);
|
||||
--color-error: var(--color-error-dark);
|
||||
--text-xxs: 11px;
|
||||
--text-xxs--line-height: 14px;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
--color-background: var(--color-background-light);
|
||||
--color-primary: var(--color-primary-light);
|
||||
|
||||
--color-background-success: var(--color-background-success-light);
|
||||
--color-success: var(--color-success-light);
|
||||
--color-background-error: var(--color-background-error-light);
|
||||
--color-error: var(--color-error-light);
|
||||
}
|
||||
|
||||
--color-high: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
--color-low: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * 1000), 1) 0 0);
|
||||
--lightness-high: color-mix(in oklch, var(--color-high) 0%, oklch(var(--color-high) 0 0));
|
||||
--lightness-low: color-mix(in oklch, var(--color-low) 0%, oklch(var(--color-low) 0 0));
|
||||
--font-family:
|
||||
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-scale: 1;
|
||||
|
||||
--font-size-xs: calc(0.75rem * var(--font-scale));
|
||||
--font-size-sm: calc(0.875rem * var(--font-scale));
|
||||
--font-size-md: calc(1rem * var(--font-scale));
|
||||
--font-size-lg: calc(1.125rem * var(--font-scale));
|
||||
--font-size-xl: calc(1.25rem * var(--font-scale));
|
||||
--font-size-2xl: calc(1.5rem * var(--font-scale));
|
||||
/* 1440px. The band borders only close into a box once the page is at least
|
||||
this wide; below it the dashed rules run to the viewport edge. */
|
||||
--width-max: 90rem;
|
||||
}
|
||||
|
||||
[data-component='root'] {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--color-background);
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--color-background-200);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
[data-component='page'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component='frame'] {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
color: var(--color-high);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The two dashed rules across the top and bottom of the page. Their inner
|
||||
element is what carries the vertical edges, so the dashes meet in a corner
|
||||
rather than crossing. */
|
||||
[data-component='band'] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
border-color: var(--color-border);
|
||||
border-style: dashed;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
[data-component='band'][data-edge='top'] {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
[data-component='band'][data-edge='bottom'] {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
[data-component='band'] > div,
|
||||
[data-component='main'] {
|
||||
width: 100%;
|
||||
max-width: var(--width-max);
|
||||
margin-inline: auto;
|
||||
flex: 1 1 0%;
|
||||
border-color: var(--color-border);
|
||||
border-style: dashed;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
[data-component='main'] {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
[data-component='band'] > div,
|
||||
[data-component='main'] {
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The 48-column field the lockup is centred in. The two dashed verticals sit
|
||||
on the column-5 and column-44 gridlines and the content spans 6 to -6, so
|
||||
there is a full column of air between a rule and any text. */
|
||||
[data-component='field'] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 1 1 0%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
grid-template-columns: repeat(48, minmax(0, 1fr));
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
[data-component='rule'] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
grid-column: 5;
|
||||
transform: translateX(-50%);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-component='rule'][data-side='end'] {
|
||||
grid-column: -5;
|
||||
}
|
||||
|
||||
[data-component='center'] {
|
||||
width: 380px;
|
||||
position: relative;
|
||||
grid-column: 6 / -6;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 34.5rem;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
&[data-size='small'] {
|
||||
width: 300px;
|
||||
}
|
||||
/* The rules run edge to edge behind this column; nothing here should eat a
|
||||
click meant for the page. Interactive descendants opt back in. */
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component='link'] {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-component='label'] {
|
||||
[data-component='stack'] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-xs);
|
||||
align-items: center;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
[data-component='logo'] {
|
||||
margin: 0 auto;
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
width: auto;
|
||||
display: none;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
&[data-mode='light'] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&[data-mode='dark'] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
[data-component='logo-default'] {
|
||||
margin: 0 auto;
|
||||
height: 2.5rem;
|
||||
[data-component='logo'] svg {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: var(--color-high);
|
||||
}
|
||||
[data-component='title'] {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.05em;
|
||||
/* Balanced on narrow screens only, where an unbalanced last line leaves a
|
||||
single orphaned word; the wide case is reset in the media query at the
|
||||
foot of this file. */
|
||||
font-family: var(--font-mona);
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: var(--color-high);
|
||||
}
|
||||
[data-component='title'] strong {
|
||||
font-weight: inherit;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
[data-component='title'] a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-component='actions'] {
|
||||
pointer-events: auto;
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component='form'] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-component='input'] {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid transparent;
|
||||
--background: oklch(
|
||||
from var(--color-background) calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c
|
||||
h
|
||||
);
|
||||
background: var(--background);
|
||||
/* Form controls do not inherit \`color\`, so without this the text someone
|
||||
types is the UA default — black, over the near-black \`--background\`
|
||||
computed just above. The page looked fine and the field was unreadable. */
|
||||
color: var(--color-high);
|
||||
caret-color: var(--color-high);
|
||||
border-color: oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
font-size: var(--font-size-sm);
|
||||
appearance: none;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-gray-300);
|
||||
background: var(--color-background-100);
|
||||
padding: 1.125rem 1.25rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
/* Stated rather than inherited: a form control does not take its parent's
|
||||
colour, and leaving it to the UA put black glyphs on this field. */
|
||||
color: var(--color-foreground);
|
||||
caret-color: var(--color-brand);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
box-shadow 150ms;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-high);
|
||||
opacity: 0.5;
|
||||
}
|
||||
[data-component='input']::placeholder {
|
||||
color: var(--color-muted-foreground2);
|
||||
}
|
||||
|
||||
/* Chrome paints its own background over autofilled fields and ignores
|
||||
\`background\`. An inset shadow is the only thing it honours, and
|
||||
\`-webkit-text-fill-color\` the only thing that moves the glyphs. */
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--color-high);
|
||||
-webkit-box-shadow: 0 0 0 100px var(--background) inset;
|
||||
caret-color: var(--color-high);
|
||||
}
|
||||
[data-component='input']:hover {
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.3, l + (-0.2 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.1), 0.7)) c h
|
||||
);
|
||||
}
|
||||
[data-component='input']:focus {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 1px var(--color-brand);
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: oklch(0.4 0.09 7.91);
|
||||
}
|
||||
/* Chrome paints its own background over an autofilled field and ignores
|
||||
\`background\`; an inset shadow is the only thing it honours. */
|
||||
[data-component='input']:-webkit-autofill,
|
||||
[data-component='input']:-webkit-autofill:hover,
|
||||
[data-component='input']:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--color-foreground);
|
||||
-webkit-box-shadow: 0 0 0 100px var(--color-background-100) inset;
|
||||
caret-color: var(--color-brand);
|
||||
}
|
||||
|
||||
[data-component='button'] {
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: oklch(from var(--color-primary) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
|
||||
&[data-color='ghost'] {
|
||||
background: transparent;
|
||||
color: var(--color-high);
|
||||
border: 1px solid
|
||||
oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
}
|
||||
|
||||
[data-slot='icon'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-gray-1000);
|
||||
padding: 1.125rem 2.5rem;
|
||||
color: var(--color-gray-100);
|
||||
font-family: var(--font-mona);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
[data-component='form'] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
[data-component='button']:hover {
|
||||
background: var(--color-gray-900);
|
||||
scale: 1.01;
|
||||
}
|
||||
|
||||
[data-component='form-alert'] {
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
background: var(--color-background-error);
|
||||
color: var(--color-error);
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
[data-component='button']:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-background-200),
|
||||
0 0 0 4px var(--color-brand);
|
||||
}
|
||||
|
||||
&[data-color='success'] {
|
||||
background: var(--color-background-success);
|
||||
color: var(--color-success);
|
||||
|
||||
[data-slot='icon-success'] {
|
||||
display: block;
|
||||
}
|
||||
[data-slot='icon-danger'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:has([data-slot='message']:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot='icon-success'],
|
||||
[data-slot='icon-danger'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
[data-slot='icon-success'] {
|
||||
display: none;
|
||||
}
|
||||
[data-component='button']:disabled {
|
||||
cursor: not-allowed;
|
||||
background: var(--color-background-100);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground2);
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
[data-component='form-footer'] {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.75rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: var(--text-xxs);
|
||||
line-height: var(--text-xxs--line-height);
|
||||
color: var(--color-muted-foreground2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&:has(> :nth-child(2)) {
|
||||
justify-content: space-between;
|
||||
[data-component='form-footer'] a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
/* The resend control, which is a button behaving as a link and does need to
|
||||
look like one. */
|
||||
[data-component='link'] {
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125rem;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
[data-component='form-footer'] a:hover,
|
||||
[data-component='link']:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
[data-component='form-alert'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-red-600);
|
||||
background: var(--color-red-100);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component='form-alert'][data-color='success'] {
|
||||
border-color: var(--color-green-600);
|
||||
background: var(--color-green-100);
|
||||
}
|
||||
|
||||
[data-component='form-alert'] svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-component='form-alert'] [data-slot='icon-success'] {
|
||||
display: none;
|
||||
color: var(--color-green-600);
|
||||
}
|
||||
|
||||
[data-component='form-alert'] [data-slot='icon-danger'] {
|
||||
display: block;
|
||||
color: var(--color-red-600);
|
||||
}
|
||||
|
||||
[data-component='form-alert'][data-color='success'] [data-slot='icon-success'] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-component='form-alert'][data-color='success'] [data-slot='icon-danger'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
[data-component='stack'] {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}`;
|
||||
|
||||
[data-component='logo'] {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
[data-component='title'] {
|
||||
font-size: 40px;
|
||||
line-height: 2.5rem;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
[data-component='form-footer'] {
|
||||
line-height: 1.625;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component='button'],
|
||||
[data-component='input'] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-component='button']:hover {
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user