Redo the login page: static milky-way sky and a droplet-glass card.

The space photo is replaced by a seeded canvas sky (nebula band, dust
lanes, stars) painted once. The card refracts the sky at its rim through
an SVG displacement filter (Chromium; plain blur elsewhere), sits further
from the right edge, and drops the glowing flare and button shadow.

Login errors now read as text instead of API codes, the last username is
remembered, and Caps Lock is flagged in the password field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-13 21:09:27 +08:00
parent e21719b66c
commit ac162dff4e
9 changed files with 497 additions and 97 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

View file

@ -0,0 +1,115 @@
import { useEffect, useState, type RefObject } from "react";
/**
* Displacement map for a rounded rect: neutral (128,128) in the middle, and
* within `bezel` px of the edge the sample point is pushed inward along the
* edge normal — the background bends at the rim like light through a droplet.
*/
function buildMap(w: number, h: number, radius: number, bezel: number): string {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(w, h);
const hx = w / 2;
const hy = h / 2;
const r = Math.min(radius, hx, hy);
// Signed distance to the rounded-rect edge (negative inside).
const sdf = (x: number, y: number) => {
const qx = Math.abs(x - hx) - (hx - r);
const qy = Math.abs(y - hy) - (hy - r);
const ox = Math.max(qx, 0);
const oy = Math.max(qy, 0);
return Math.hypot(ox, oy) + Math.min(Math.max(qx, qy), 0) - r;
};
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const px = x + 0.5;
const py = y + 0.5;
const depth = -sdf(px, py);
let dx = 0;
let dy = 0;
if (depth < bezel) {
// Outward normal from the SDF gradient.
const gx = sdf(px + 1, py) - sdf(px - 1, py);
const gy = sdf(px, py + 1) - sdf(px, py - 1);
const gl = Math.hypot(gx, gy) || 1;
const t = 1 - Math.max(depth, 0) / bezel;
const f = t * t; // strongest right at the rim, fading inward
dx = -(gx / gl) * f;
dy = -(gy / gl) * f;
}
img.data[i] = Math.round(128 + dx * 127);
img.data[i + 1] = Math.round(128 + dy * 127);
img.data[i + 2] = 128;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
}
/**
* SVG filter that refracts whatever sits behind `target`; reference it with
* `backdrop-filter: url(#id)` (Chromium only — other engines keep the plain
* blur declared before it).
*/
export function DropletGlassFilter({
id,
target,
radius = 30,
bezel = 26,
strength = 46,
}: {
id: string;
target: RefObject<HTMLElement | null>;
radius?: number;
bezel?: number;
strength?: number;
}) {
const [map, setMap] = useState<{ url: string; w: number; h: number } | null>(null);
useEffect(() => {
const el = target.current;
if (!el) return;
let timer = 0;
const rebuild = () => {
const w = Math.round(el.offsetWidth);
const h = Math.round(el.offsetHeight);
if (!w || !h) return;
setMap((prev) => (prev && prev.w === w && prev.h === h ? prev : { url: buildMap(w, h, radius, bezel), w, h }));
};
const ro = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(rebuild, 60);
});
ro.observe(el);
rebuild();
return () => {
ro.disconnect();
window.clearTimeout(timer);
};
}, [target, radius, bezel]);
return (
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true" focusable="false">
<defs>
<filter id={id} colorInterpolationFilters="sRGB">
{map ? (
<>
<feImage href={map.url} x="0" y="0" width={map.w} height={map.h} result="map" preserveAspectRatio="none" />
<feGaussianBlur in="SourceGraphic" stdDeviation="1.6" result="soft" />
<feDisplacementMap in="soft" in2="map" scale={strength} xChannelSelector="R" yChannelSelector="G" />
</>
) : (
<feGaussianBlur in="SourceGraphic" stdDeviation="1.6" />
)}
</filter>
</defs>
</svg>
);
}

View file

@ -1,19 +1,46 @@
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "./AuthContext";
import { useI18n } from "../../i18n";
import { LoginShell } from "./LoginShell";
import { DropletGlassFilter } from "./DropletGlass";
import { ApiRequestError } from "../../services/api/http";
const LAST_USER_KEY = "netx.login.lastUser";
function readLastUser(): string {
try {
return localStorage.getItem(LAST_USER_KEY) || "";
} catch {
return "";
}
}
/** Map the login endpoint's error codes to readable text. */
function loginErrorText(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
if (err instanceof ApiRequestError) {
if (err.detail === "invalid_credentials") return t("auth.badCredentials");
const d = err.detail as { error?: string; retry_after_sec?: number } | null;
if (d && typeof d === "object" && d.error === "login_locked") {
return t("auth.loginLocked", { minutes: Math.max(1, Math.ceil((d.retry_after_sec || 60) / 60)) });
}
}
return String(err instanceof Error ? err.message : err) || t("auth.loginFailed");
}
export function LoginPage() {
const { t } = useI18n();
const { ready, user, login } = useAuth();
const [params] = useSearchParams();
const [username, setUsername] = useState("admin");
const [lastUser] = useState(readLastUser);
const [username, setUsername] = useState(lastUser || "admin");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [capsLock, setCapsLock] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
if (ready && user) {
const next = params.get("next") || "/";
@ -24,23 +51,32 @@ export function LoginPage() {
e.preventDefault();
setError("");
setBusy(true);
const name = username.trim();
try {
await login(username.trim(), password);
await login(name, password);
try {
localStorage.setItem(LAST_USER_KEY, name);
} catch {
/* storage blocked — nothing to remember */
}
} catch (err) {
setError(String(err instanceof Error ? err.message : err) || t("auth.loginFailed"));
setError(loginErrorText(err, t));
} finally {
setBusy(false);
}
};
const syncCaps = (e: KeyboardEvent) => setCapsLock(e.getModifierState("CapsLock"));
return (
<LoginShell>
<Card className="login-card login-card--heroui" variant="transparent">
<DropletGlassFilter id="login-droplet" target={cardRef} />
<Card ref={cardRef} className="login-card login-card--heroui" variant="transparent">
<Card.Header className="login-card__head">
<Card.Title className="login-card__title">{t("auth.loginTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX">
<div className="login-card__brand" aria-hidden="true">
NETX
</div>
<Card.Title className="login-card__sr">{t("auth.loginTitle")}</Card.Title>
</Card.Header>
<Card.Content>
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
@ -48,28 +84,36 @@ export function LoginPage() {
fullWidth
name="username"
value={username}
onChange={setUsername}
onChange={(v) => {
setUsername(v);
setError("");
}}
isDisabled={busy || !ready}
autoFocus
autoFocus={!lastUser}
autoComplete="username"
>
<Label className="login-card__sr">{t("auth.username")}</Label>
<Input placeholder={t("auth.username")} />
</TextField>
<div className="login-card__password">
<div className="login-card__password" onKeyDown={syncCaps} onKeyUp={syncCaps}>
<TextField
fullWidth
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
onChange={(v) => {
setPassword(v);
setError("");
}}
isDisabled={busy || !ready}
autoFocus={!!lastUser}
autoComplete="current-password"
>
<Label className="login-card__sr">{t("auth.password")}</Label>
<Input placeholder={t("auth.password")} />
</TextField>
{capsLock ? <span className="login-card__caps">{t("auth.capsLock")}</span> : null}
<button
type="button"
className="login-card__eye"

View file

@ -1,6 +1,7 @@
import { useEffect, type ReactNode } from "react";
import { LoginStarfield } from "./LoginStarfield";
/** Auth shell: night-earth stage on the left, form rail on the right. */
/** Auth shell: night earth adrift in deep space on the left, form rail on the right. */
export function LoginShell({ children }: { children: ReactNode }) {
// Pause ambient CSS loops while the tab is hidden.
useEffect(() => {
@ -19,16 +20,13 @@ export function LoginShell({ children }: { children: ReactNode }) {
return (
<div className="login-page">
<div className="login-page__space" aria-hidden="true">
<LoginStarfield />
<div className="login-planet">
<div className="login-planet__earth">
<div className="login-planet__textures" />
</div>
</div>
</div>
{/* Sibling of space so blooms sit above the stage, under the login card. */}
<div className="login-page__flares" aria-hidden="true">
<span className="login-page__flare login-page__flare--a" />
</div>
<aside className="login-page__rail">{children}</aside>
</div>
);

View file

@ -0,0 +1,198 @@
import { useEffect, useRef } from "react";
/** Seeded PRNG so the sky is the same on every visit. */
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function gauss(rand: () => number) {
return Math.sqrt(-2 * Math.log(rand() || 1e-9)) * Math.cos(2 * Math.PI * rand());
}
/** Larger stars, drawn last with a small soft halo. */
type BrightStar = { x: number; y: number; r: number; a: number; hue: number; sat: number };
/**
* Milky-way band running lower-left → upper-right, behind the earth.
* Returns a point on the band and the unit normal, both in px.
*/
function bandFrame(w: number, h: number) {
const x0 = -0.1 * w;
const y0 = 0.9 * h;
const x1 = 1.1 * w;
const y1 = 0.2 * h;
const len = Math.hypot(x1 - x0, y1 - y0);
const ux = (x1 - x0) / len;
const uy = (y1 - y0) / len;
return { x0, y0, len, ux, uy, nx: -uy, ny: ux };
}
/** Static sky: gradient, nebula band, dust lanes, distant galaxies, thousands of stars. */
function paintSky(ctx: CanvasRenderingContext2D, w: number, h: number): BrightStar[] {
const rand = mulberry32(20261013);
const diag = Math.hypot(w, h);
const band = bandFrame(w, h);
const along = (t: number, off: number) => ({
x: band.x0 + band.ux * band.len * t + band.nx * off,
y: band.y0 + band.uy * band.len * t + band.ny * off,
});
const bg = ctx.createLinearGradient(0, 0, w, h);
bg.addColorStop(0, "#01040b");
bg.addColorStop(0.45, "#020916");
bg.addColorStop(1, "#010409");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
// Nebula clouds along the band (hues 195–215, never violet).
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < 90; i++) {
const t = rand();
const p = along(t, gauss(rand) * diag * 0.06);
const r = diag * (0.04 + rand() * 0.11);
const hue = 196 + rand() * 18;
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
const a = 0.02 + rand() * 0.045;
g.addColorStop(0, `hsla(${hue}, 70%, ${38 + rand() * 18}%, ${a})`);
g.addColorStop(1, `hsla(${hue}, 70%, 30%, 0)`);
ctx.fillStyle = g;
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2);
}
// Pale core of the band.
for (let i = 0; i < 36; i++) {
const p = along(0.15 + rand() * 0.7, gauss(rand) * diag * 0.02);
const r = diag * (0.03 + rand() * 0.05);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
g.addColorStop(0, `hsla(205, 35%, 78%, ${0.014 + rand() * 0.02})`);
g.addColorStop(1, "hsla(205, 35%, 70%, 0)");
ctx.fillStyle = g;
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2);
}
// Dark dust lanes cutting the band.
ctx.globalCompositeOperation = "source-over";
for (let i = 0; i < 46; i++) {
const p = along(rand(), diag * (0.008 + gauss(rand) * 0.014));
const r = diag * (0.015 + rand() * 0.045);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
g.addColorStop(0, `rgba(1, 3, 8, ${0.18 + rand() * 0.22})`);
g.addColorStop(1, "rgba(1, 3, 8, 0)");
ctx.fillStyle = g;
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2);
}
// A few far galaxies: tiny tilted smudges.
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < 4; i++) {
const x = w * (0.08 + rand() * 0.84);
const y = h * (0.06 + rand() * 0.88);
const rx = 6 + rand() * 10;
ctx.save();
ctx.translate(x, y);
ctx.rotate(rand() * Math.PI);
ctx.scale(1, 0.28 + rand() * 0.3);
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
g.addColorStop(0, "hsla(205, 40%, 82%, 0.22)");
g.addColorStop(0.35, "hsla(205, 45%, 60%, 0.06)");
g.addColorStop(1, "hsla(205, 45%, 50%, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, rx, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// Stars: half scattered, half crowding the band.
const count = Math.round((w * h) / 300);
const bright: BrightStar[] = [];
for (let i = 0; i < count; i++) {
let x: number;
let y: number;
const inBand = rand() < 0.55;
if (inBand) {
const p = along(rand(), gauss(rand) * diag * 0.055);
x = p.x;
y = p.y;
} else {
x = rand() * w;
y = rand() * h;
}
if (x < 0 || y < 0 || x > w || y > h) continue;
const m = rand();
const r = m < 0.86 ? 0.25 + rand() * 0.45 : m < 0.985 ? 0.6 + rand() * 0.55 : 1.1 + rand() * 0.6;
const a = Math.min(0.85, (inBand ? 0.12 : 0.18) + rand() * 0.5 + (r > 1 ? 0.2 : 0));
// Mostly blue-white; a few pale warm stars for realism.
const warm = rand() < 0.12;
const hue = warm ? 38 + rand() * 12 : 200 + rand() * 16;
const sat = warm ? 45 : 25 + rand() * 30;
const light = 82 + rand() * 12;
if (r > 1 && bright.length < 90) {
bright.push({ x, y, r, a, hue, sat });
continue;
}
ctx.fillStyle = `hsla(${hue}, ${sat}%, ${light}%, ${a})`;
if (r < 0.6) {
ctx.fillRect(x, y, r * 1.6, r * 1.6);
} else {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalCompositeOperation = "source-over";
return bright;
}
function drawStar(ctx: CanvasRenderingContext2D, s: BrightStar) {
const halo = s.r * 3.2;
const g = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, halo);
g.addColorStop(0, `hsla(${s.hue}, ${s.sat}%, 92%, ${s.a})`);
g.addColorStop(0.3, `hsla(${s.hue}, ${s.sat}%, 80%, ${s.a * 0.3})`);
g.addColorStop(1, `hsla(${s.hue}, ${s.sat}%, 70%, 0)`);
ctx.fillStyle = g;
ctx.fillRect(s.x - halo, s.y - halo, halo * 2, halo * 2);
}
/** Procedural deep-space backdrop, painted once (and again on resize). Fully static. */
export function LoginStarfield() {
const skyRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const sky = skyRef.current;
const ctx = sky?.getContext("2d");
if (!sky || !ctx) return;
let resizeTimer = 0;
const paint = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
sky.width = Math.round(w * dpr);
sky.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const s of paintSky(ctx, w, h)) drawStar(ctx, s);
};
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(paint, 150);
};
paint();
window.addEventListener("resize", onResize);
return () => {
window.clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
};
}, []);
return <canvas ref={skyRef} className="login-sky" aria-hidden="true" />;
}

View file

@ -5,6 +5,9 @@ const auth = {
login: "Sign in",
loggingIn: "Signing in…",
loginFailed: "Login failed",
capsLock: "Caps Lock",
badCredentials: "Wrong username or password",
loginLocked: "Too many attempts. Try again in {{minutes}} min",
logout: "Sign out",
sessionsTitle: "Login sessions",
sessionsHelp:

View file

@ -5,6 +5,9 @@ const auth = {
login: "登录",
loggingIn: "登录中…",
loginFailed: "登录失败",
capsLock: "大写已开",
badCredentials: "用户名或密码错误",
loginLocked: "尝试次数过多,请 {{minutes}} 分钟后再试",
logout: "退出",
sessionsTitle: "登录会话",
sessionsHelp: "反代后 IP 取真实客户端;同公网可用「客户端」列区分。踢掉后需重新登录。",

View file

@ -30,7 +30,17 @@
/* Center earth within the left stage (not under the form rail). */
justify-content: center;
padding: 3vh calc(var(--login-rail-w) + 3vw) 3vh 5vw;
background: url("/login-space.webp") center / cover no-repeat;
background: #01040b;
}
/* Procedural sky (LoginStarfield), painted once. */
.login-sky {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.login-page__space::before {
@ -51,39 +61,6 @@
linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 72%, rgba(0, 0, 0, 0.38) 100%);
}
/* Tiny sun flare — upper-left void. */
.login-page__flares {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
overflow: hidden;
}
.login-page__flare {
position: absolute;
border-radius: 50%;
pointer-events: none;
opacity: 0.85;
}
.login-page__flare--a {
width: 44px;
height: 44px;
left: 7%;
top: 11%;
background:
radial-gradient(
circle at 50% 50%,
rgba(245, 250, 255, 0.92) 0%,
rgba(170, 210, 255, 0.48) 28%,
rgba(90, 150, 245, 0.14) 55%,
transparent 78%
);
box-shadow: 0 0 16px 3px rgba(120, 180, 255, 0.2);
animation: login-flare-pulse-a 10s ease-in-out infinite alternate;
}
.login-planet {
position: relative;
z-index: 1;
@ -458,14 +435,8 @@
}
}
@keyframes login-flare-pulse-a {
from { opacity: 0.7; transform: scale(0.96); }
to { opacity: 1; transform: scale(1.08); }
}
/* Pause infinite loops when the login tab is backgrounded (class set by LoginShell). */
html.login-page--paused .login-planet__textures,
html.login-page--paused .login-page__flare {
html.login-page--paused .login-planet__textures {
animation-play-state: paused !important;
}
@ -475,8 +446,7 @@ html.login-page--paused .login-page__flare {
.login-planet__earth::after,
.login-card,
.login-card__brand,
.login-card__error,
.login-page__flare {
.login-card__error {
animation: none !important;
}
@ -490,10 +460,6 @@ html.login-page--paused .login-page__flare {
animation-iteration-count: infinite !important;
}
.login-page__flare {
opacity: 0.85;
}
.login-planet {
transform: rotate(5deg);
}

View file

@ -446,44 +446,73 @@ a,
.login-page {
--login-accent: var(--nx-accent-strong);
--login-muted: var(--nx-text-muted);
--login-field: rgb(1 5 12 / 0.32);
--login-field-border: rgb(150 190 255 / 0.08);
--login-rail-w: clamp(440px, 36vw, 560px);
}
/* Pull the card in from the right edge; the earth re-centers on the
remaining stage because .login-page__space pads by --login-rail-w. */
.login-page__rail {
padding: 48px clamp(64px, 8vw, 140px) 48px 0;
}
/* Droplet glass: almost clear, the rim refracts the stars behind it
(DropletGlassFilter, Chromium) with a crisp specular edge and a soft
contact shadow. Non-Chromium engines keep the plain blur line. */
.login-card {
max-width: 372px;
gap: 18px;
padding: 32px 30px 30px;
border-radius: 20px;
border-color: rgb(255 255 255 / 0.12);
background: rgb(var(--nx-ink-rgb) / 0.78);
backdrop-filter: blur(14px) saturate(1.15);
-webkit-backdrop-filter: blur(14px) saturate(1.15);
position: relative;
max-width: 360px;
gap: 22px;
padding: 38px 32px 32px;
border-radius: 30px;
border: 0;
background:
radial-gradient(120% 70% at 30% 0%, rgb(190 220 255 / 0.06) 0%, transparent 60%),
rgb(3 9 20 / 0.16);
-webkit-backdrop-filter: blur(3px) saturate(150%) brightness(0.9);
backdrop-filter: blur(3px) saturate(150%) brightness(0.9);
backdrop-filter: url(#login-droplet) saturate(150%) brightness(0.9);
box-shadow:
inset 0 1px 1px rgb(220 235 255 / 0.22),
inset 0 -1px 1px rgb(220 235 255 / 0.06),
inset 14px 18px 28px -22px rgb(190 220 255 / 0.22),
inset -14px -18px 28px -22px rgb(120 170 230 / 0.14),
0 24px 48px -12px rgb(0 0 0 / 0.55),
0 4px 10px rgb(0 0 0 / 0.25);
}
/* Specular ring: brightest where light enters (top-left) and exits (bottom-right). */
.login-card::before {
content: "";
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg,
rgb(210 230 255 / 0.42) 0%, rgb(190 215 255 / 0.12) 22%, rgb(170 205 255 / 0.03) 45%,
rgb(170 205 255 / 0.03) 60%, rgb(170 205 255 / 0.12) 82%, rgb(200 225 255 / 0.30) 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
pointer-events: none;
}
.login-card__head {
flex-direction: column-reverse;
align-items: flex-start;
gap: 4px;
margin-bottom: 4px;
margin: 0;
}
.login-card__brand {
font-size: 2rem;
font-weight: 800;
letter-spacing: 0.28em;
background: linear-gradient(90deg, #ffffff 0%, #8fc2ff 100%);
font-size: 1.9rem;
font-weight: 750;
letter-spacing: 0.3em;
background: linear-gradient(90deg, var(--nx-text-strong) 0%, var(--nx-accent-text) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
}
.login-card__title {
font-size: 14px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--nx-text-muted);
}
/* The username TextField sits outside .login-card__label/__password, so it
rendered with HeroUI defaults and did not match the password field. */
.login-card__form input {
@ -491,32 +520,76 @@ a,
height: 46px;
padding: 0 14px;
border: 1px solid var(--login-field-border);
border-radius: 12px;
border-radius: 16px;
background: var(--login-field);
color: #fff;
box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.35), 0 1px 0 rgb(200 225 255 / 0.05);
color: var(--nx-text-strong);
font: inherit;
font-size: 15px;
box-sizing: border-box;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
transition: border-color 160ms ease, background 160ms ease;
}
.login-card__form input::placeholder {
color: rgb(255 255 255 / 0.45);
color: var(--nx-text-faint);
}
.login-card__form input:focus {
outline: none;
border-color: rgb(var(--nx-accent-rgb) / 0.8);
box-shadow: 0 0 0 3px rgb(var(--nx-accent-rgb) / 0.25);
background: rgb(255 255 255 / 0.1);
border-color: rgb(var(--nx-accent-rgb) / 0.5);
box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.35), 0 1px 0 rgb(200 225 255 / 0.05);
background: rgb(1 5 12 / 0.42);
}
.login-card__form {
gap: 14px;
}
.login-card__submit {
height: 46px;
border-radius: 12px;
letter-spacing: 0.08em;
.login-card__eye {
color: var(--nx-text-muted);
}
.login-card__caps {
position: absolute;
top: 50%;
right: 44px;
z-index: 2;
transform: translateY(-50%);
padding: 2px 7px;
border-radius: 6px;
background: rgb(var(--nx-warn-rgb) / 0.12);
color: var(--nx-warn);
font-size: 11px;
line-height: 1.5;
pointer-events: none;
}
.login-card__error {
color: var(--nx-bad-text);
background: rgb(var(--nx-bad-rgb) / 0.1);
border-color: rgb(var(--nx-bad-rgb) / 0.22);
}
.login-card__submit {
margin-top: 6px;
height: 46px;
border-radius: 16px;
background:
linear-gradient(180deg, rgb(255 255 255 / 0.14) 0%, rgb(255 255 255 / 0) 55%),
var(--nx-gradient-accent);
color: var(--nx-text-strong);
letter-spacing: 0.08em;
box-shadow:
inset 0 1px 1px rgb(255 255 255 / 0.25),
inset 0 -1px 2px rgb(0 0 0 / 0.2),
0 6px 14px -6px rgb(0 0 0 / 0.6);
filter: brightness(0.86);
}
.login-card__submit:hover:not(:disabled) {
filter: brightness(0.94);
}
.login-card__submit:active:not(:disabled) {
filter: brightness(0.82);
}