mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
fix(auth): make the sign-in screen readable, and draw it in the product's design language (#338)
## Why Two separate faults on the same screen, found while trying to sign in for the first time. **The email field was unreadable.** `[data-component='input']` computed its own background one step lighter than the page and never set `color`. Form controls do not inherit it, so the text someone typed was the UA default — black glyphs over a near-black field. There was no `color-scheme` either, so the browser rendered the control in light appearance to begin with. That rule was the only `input` selector in the stylesheet. **The screen was still the upstream template's** — its font, its accent, its logo. `issuer()` takes a `theme` and calls `setTheme`, but nothing had passed one since `packages/auth` became a vendored fork. ## What changed The theme is restored and the page is redrawn in the design language the rest of the product uses: 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 product opens with. **Dark only.** The scheme that let one theme serve both light and dark derived every colour from the background's lightness through `oklch(from ...)`, and that derivation is exactly what left the field's text the colour of its own background. Values are now stated, not computed. The brand colour appears in two places: the wordmark, and the field's border on focus. ## Evidence Measured against a render of the same design built from its own source, at 1280×900: | | reference | this | |---|---|---| | band rules | y `79`, `820` | y `79`, `820` | | column rules | x `106`, `1172` | x `106`, `1172` | | wordmark box | x `505–774`, h `45` | x `505–774`, h `45` | | heading span | `66px` | `66px` | | button height | `60px` | `60px` | Both screens (email and code) and a 390px viewport were rendered and looked at, not just diffed. `bun test packages/auth` → 66 pass, 0 fail. Typecheck, `oxfmt` and `oxlint` clean. ## What this does not verify - **Nothing has been signed in with.** The screens were rendered directly from `CodeUI`; no code has been minted, mailed or redeemed through this page. That is the next thing, and it happens on `auth.nestri.io` after this merges. - **Fonts are a new third-party runtime dependency on the sign-in path.** Mona Sans and Geist come from the jsdelivr Fontsource CDN, because self-hosted font packages need a bundler and nothing preprocesses this page. If jsdelivr is unreachable the page falls back to `system-ui` and stays usable, but serving them from our own origin is probably the right end state. - **Only Chromium was used.** The autofill rules are `-webkit-` prefixed and were reasoned about, not observed; no Firefox or Safari render was taken. - **`docs/deploy.md` is stale** and not touched here. It still says Cloudflare Workers is "what production and sandbox are today"; production is long-lived processes on a VM behind a tunnel. Worth a separate change.
This commit is contained in:
@@ -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<string, unknown>);
|
||||
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) => {
|
||||
|
||||
@@ -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,247 +1,434 @@
|
||||
export default `:root {
|
||||
--color-background-dark: #0e0e11;
|
||||
--color-background-light: #ffffff;
|
||||
--color-primary-dark: #6772e5;
|
||||
--color-primary-light: #6772e5;
|
||||
/**
|
||||
* 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 assembled as a
|
||||
* string at request time and nothing preprocesses it, 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-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-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-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);
|
||||
--color-background-100: hsl(0 0% 4%);
|
||||
--color-background-200: hsl(0 0% 0%);
|
||||
|
||||
--border-radius: 0;
|
||||
/* 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);
|
||||
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-primary: var(--color-primary-dark);
|
||||
--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-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);
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mona: 'Mona Sans Variable', var(--font-sans);
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
--color-background: var(--color-background-light);
|
||||
--color-primary: var(--color-primary-light);
|
||||
--text-xxs: 11px;
|
||||
--text-xxs--line-height: 14px;
|
||||
|
||||
--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);
|
||||
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: 0.9rem 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;
|
||||
}
|
||||
|
||||
&: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']::placeholder {
|
||||
color: var(--color-muted-foreground2);
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: oklch(0.4 0.09 7.91);
|
||||
}
|
||||
[data-component='input']:hover {
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
[data-component='input']:focus {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 1px var(--color-brand);
|
||||
}
|
||||
|
||||
/* 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