CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
The theme foundation DESIGN.md requires (ADR 0060): - A design token may carry a `dark` value and a `reduced` value. The token stylesheet selects them in CSS alone: dark under prefers-color-scheme unless the host chose light, or when it chose dark; motion durations zero under prefers-reduced-motion unless it allowed motion, or when it chose reduce. `color-scheme` travels with the tokens. System is live, an explicit choice wins, and every module root follows through custom-property inheritance, with no module contract change. - @ekklesia/design-tokens adds a small appearance API: system/light/dark and system/reduce/allow, parsers for untrusted stored values, resolvers, and a controller that applies the attributes immediately. The shell applies it before React renders. That is the hook the account preferences work will feed; nothing is persisted. The Dashboard page declares `color-scheme: light dark` so a dark system never flashes white. Displays stay light. - A designed dark theme: teal-tinted charcoal, never pure black or white, the same teal accent. - WCAG AA contrast is tested for 41 element pairings in both schemes. Two light pairings failed and are fixed visibly: text.muted darkens, and form controls get a new border.control (3:1). background.placeholder keeps skeletons visible in dark. - Motion tokens (duration-short, duration-ambient) replace literal durations and every per-element reduced-motion rule. radius.scale multiplies sm/md/lg, unchanged at its default of 1. - The sign-in card and module loading/error text move from literal colours to tokens. Guards keep raw colours and literal radii out of Dashboard product source and @ekklesia/ui. - Fixes ConfirmationDialog's phrase input, which used a class that does not exist (`ek-input`) and so was unstyled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
import { designToken } from "@ekklesia/design-tokens";
|
|
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
|
|
import { HostAuthError } from "../auth-protocol.js";
|
|
|
|
/**
|
|
* The shared visual language of every host-owned auth screen.
|
|
*
|
|
* Exported so the login, recovery, reset, and verification screens are literally the same card
|
|
* rather than four that drift apart. Modules never render any of them. Every colour, radius, and
|
|
* size is a design token (ADR 0039, ADR 0060), so these screens follow the person's light, dark, or
|
|
* system appearance like the rest of the Dashboard; the action is the platform's primary button.
|
|
*/
|
|
export const authStyles = {
|
|
button: {
|
|
background: designToken("accent.base"),
|
|
border: `${designToken("border.width")} solid ${designToken("accent.base")}`,
|
|
borderRadius: designToken("radius.md"),
|
|
color: designToken("accent.foreground"),
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
fontWeight: designToken("typography.weight-bold"),
|
|
minHeight: designToken("control.height-md"),
|
|
padding: `${designToken("spacing.sm")} ${designToken("spacing.lg")}`,
|
|
},
|
|
card: {
|
|
background: designToken("background.raised"),
|
|
border: `${designToken("border.width")} solid ${designToken("border.base")}`,
|
|
borderRadius: designToken("radius.lg"),
|
|
boxShadow: designToken("shadow.md"),
|
|
display: "grid",
|
|
gap: designToken("spacing.lg"),
|
|
maxWidth: "24rem",
|
|
padding: designToken("spacing.xl"),
|
|
width: "100%",
|
|
},
|
|
error: { color: designToken("text.danger"), margin: 0 },
|
|
field: { display: "grid", gap: designToken("spacing.xs") },
|
|
form: { display: "grid", gap: designToken("spacing.lg") },
|
|
heading: { fontSize: designToken("typography.size-heading-2"), margin: 0 },
|
|
host: {
|
|
alignItems: "center",
|
|
background: designToken("background.page"),
|
|
color: designToken("text.primary"),
|
|
display: "flex",
|
|
fontFamily: designToken("typography.family-sans"),
|
|
justifyContent: "center",
|
|
minHeight: "100vh",
|
|
padding: designToken("spacing.lg"),
|
|
},
|
|
input: {
|
|
background: designToken("background.surface"),
|
|
border: `${designToken("border.width")} solid ${designToken("border.control")}`,
|
|
borderRadius: designToken("radius.md"),
|
|
boxSizing: "border-box",
|
|
color: designToken("text.primary"),
|
|
font: "inherit",
|
|
minHeight: designToken("control.height-md"),
|
|
padding: `${designToken("spacing.sm")} ${designToken("control.padding-inline")}`,
|
|
width: "100%",
|
|
},
|
|
introduction: { color: designToken("text.secondary"), margin: 0 },
|
|
label: { fontWeight: designToken("typography.weight-bold") },
|
|
secondaryAction: {
|
|
background: "transparent",
|
|
border: "none",
|
|
color: designToken("accent.strong"),
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
justifySelf: "start",
|
|
minHeight: designToken("control.height-sm"),
|
|
padding: 0,
|
|
textDecoration: "underline",
|
|
},
|
|
} as const;
|
|
|
|
export interface AuthCardProps {
|
|
readonly headingId: string;
|
|
readonly heading: string;
|
|
readonly testId: string;
|
|
readonly children: React.ReactNode;
|
|
}
|
|
|
|
/** One centred card on the sign-in background, with a labelled region for assistive technology. */
|
|
export function AuthCard(props: AuthCardProps): ReactElement {
|
|
return (
|
|
<main data-testid={props.testId} style={authStyles.host}>
|
|
<section aria-labelledby={props.headingId} style={authStyles.card}>
|
|
<h1 id={props.headingId} style={authStyles.heading}>
|
|
{props.heading}
|
|
</h1>
|
|
{props.children}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export interface UseSingleSubmissionResult {
|
|
readonly pending: boolean;
|
|
readonly errorMessage: string | undefined;
|
|
readonly setErrorMessage: (message: string | undefined) => void;
|
|
/** Runs `operation` at most once at a time. A second submit while one is in flight is dropped. */
|
|
readonly submit: (
|
|
event: FormEvent<HTMLFormElement>,
|
|
operation: (signal: AbortSignal) => Promise<void>,
|
|
) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The submission discipline every auth form needs, in one place.
|
|
*
|
|
* A `ref` closes the same-tick gap before React commits `pending`, which is what actually prevents a
|
|
* double submission — a disabled attribute alone cannot, because it is not applied until after the
|
|
* commit. Every one of these forms triggers an email or changes a credential, so a duplicate is not
|
|
* merely untidy.
|
|
*/
|
|
export function useSingleSubmission(fallbackMessage: string): UseSingleSubmissionResult {
|
|
const [pending, setPending] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
const pendingRef = useRef(false);
|
|
const attemptRef = useRef<AbortController | undefined>(undefined);
|
|
const mountedRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
attemptRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
const submit = async (
|
|
event: FormEvent<HTMLFormElement>,
|
|
operation: (signal: AbortSignal) => Promise<void>,
|
|
): Promise<void> => {
|
|
event.preventDefault();
|
|
if (pendingRef.current) {
|
|
return;
|
|
}
|
|
pendingRef.current = true;
|
|
setPending(true);
|
|
setErrorMessage(undefined);
|
|
const attempt = new AbortController();
|
|
attemptRef.current = attempt;
|
|
try {
|
|
await operation(attempt.signal);
|
|
} catch (error) {
|
|
if (!attempt.signal.aborted && mountedRef.current) {
|
|
// A `HostAuthError` message is fixed text this package wrote. Anything else is an unknown
|
|
// thrown value whose message could contain a URL or a provider detail, so it is replaced.
|
|
setErrorMessage(error instanceof HostAuthError ? error.message : fallbackMessage);
|
|
}
|
|
} finally {
|
|
if (attemptRef.current === attempt) {
|
|
attemptRef.current = undefined;
|
|
pendingRef.current = false;
|
|
if (mountedRef.current) {
|
|
setPending(false);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
return { errorMessage, pending, setErrorMessage, submit };
|
|
}
|