SessionGuardProvider
Compose session warnings and sign-in dialogs while keeping surrounding drafts mounted.
import { cn } from "cn"
import { useRef, useState, type ReactNode, type RefObject } from "react"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { SessionGuardActionLabel, SessionGuardDialog, SessionGuardError, SessionGuardProvider, SessionGuardReauthenticate, SessionGuardWarning, SessionGuardWarningText, useSessionGuard, type SessionGuardProviderProps } from "@/components/ui/session-guard"
export default function SessionGuardDemo() {
const [expiresAt, setExpiresAt] = useState(() => Date.now() + 30_000)
const [note, setNote] = useState("")
const draft = useRef<HTMLInputElement>(null)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Expire session</button>
</div>
<div className="flex min-h-72 w-sm max-w-full flex-col justify-center gap-3 text-xs">
<SessionNotice expiresAt={expiresAt} warnMs={60_000} fallbackFocusRef={draft} onReauthenticate={async () => {
setExpiresAt(Date.now() + 300_000)
return true
}}>
<p className="text-sm text-muted-foreground">This example signs in immediately.</p>
</SessionNotice>
<label className="flex flex-col gap-1.5">
Draft note
<input ref={draft} className="rounded border border-border bg-background px-3 py-2" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Kept through sign-in" />
</label>
</div>
</>
)
}
export interface SessionNoticeProps extends Omit<SessionGuardProviderProps, "children"> {
children?: ReactNode
className?: string
fallbackFocusRef: RefObject<HTMLElement | null>
}
export function SessionNotice({ children, className, fallbackFocusRef, ...props }: SessionNoticeProps) {
return <SessionGuardProvider {...props}><SessionNoticeContent className={className} fallbackFocusRef={fallbackFocusRef}>{children}</SessionNoticeContent></SessionGuardProvider>
}
function SessionNoticeContent({ children, className, fallbackFocusRef }: Pick<SessionNoticeProps, "children" | "className" | "fallbackFocusRef">) {
const { phase, labels, pending } = useSessionGuard()
return (
<div data-session-phase={phase} className={cn(phase === "warning" ? "block" : "contents", className)}>
<SessionGuardWarning fallbackFocusRef={fallbackFocusRef}>
<SessionGuardWarningText />
<SessionGuardReauthenticate size="sm" variant="outline" className="h-7"><SessionGuardActionLabel /></SessionGuardReauthenticate>
<SessionGuardError />
</SessionGuardWarning>
<SessionGuardDialog fallbackFocusRef={fallbackFocusRef}>
<DialogHeader>
<DialogTitle>{labels.expiredTitle}</DialogTitle>
<DialogDescription>{labels.expiredDescription}</DialogDescription>
</DialogHeader>
{children}
<div className="flex flex-wrap items-center gap-2">
<SessionGuardReauthenticate>{pending ? labels.pending : labels.reauthenticate}</SessionGuardReauthenticate>
<SessionGuardError />
</div>
</SessionGuardDialog>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/session-guard#v2.0.0
npx shadcn@latest add tradecn/ui/session-guard#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/session-guard#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/session-guard#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add button dialognpx shadcn@latest add button dialogyarn dlx shadcn@latest add button dialogbunx --bun shadcn@latest add button dialog -
Copy the files into your project:
components/ui/ session-guard.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode, type RefObject } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent } from "@/components/ui/dialog" import { useNow } from "@/hooks/use-clock" import { sharedClock, type Clock } from "@/lib/clock" import { NUMERIC_CLASS } from "@/lib/format" import { countdownTier, formatRemaining } from "@/components/ui/countdown" // One owner coordinates the session phase and sign-in request. Callers compose the warning, // modal contents and controls; only time readings subscribe to every clock tick. export type SessionPhase = "none" | "live" | "warning" | "expired" export interface SessionStatusValue { phase: SessionPhase /** Milliseconds until the session ends, negative past it, null with no session. */ remainingMs: number | null expiresAt: number | null } export interface SessionGuardLabels { /** The banner's sentence; `{remaining}` is where the countdown goes. */ warning: string /** The banner's button. */ extend: string /** The dialog. */ expiredTitle: string expiredDescription: string reauthenticate: string /** While `onReauthenticate` is out, and when it said no. */ pending: string failed: string /** The status readout's word, and its phases as words for a screen reader. */ session: string live: string ending: string ended: string noSession: string } export const DEFAULT_SESSION_GUARD_LABELS: SessionGuardLabels = { warning: "Your session ends in {remaining}.", extend: "Stay signed in", expiredTitle: "Your session has ended", expiredDescription: "Sign in again to carry on. Nothing on the desk has been lost.", reauthenticate: "Sign in again", pending: "Signing in…", failed: "That did not work. Try again.", session: "Session", live: "signed in", ending: "ending soon", ended: "ended", noSession: "no session", } /** The warning window: two minutes before the end. */ export const DEFAULT_WARN_MS = 120_000 /** The phase of a session at a moment: none without an end, expired at or past it, warning within `warnMs` of it, live before that. */ export function sessionStatus(expiresAt: number | null | undefined, now: number, warnMs: number = DEFAULT_WARN_MS): SessionStatusValue { if (expiresAt === null || expiresAt === undefined || !Number.isFinite(expiresAt)) return { phase: "none", remainingMs: null, expiresAt: null } const remainingMs = expiresAt - now return { phase: remainingMs <= 0 ? "expired" : remainingMs <= warnMs ? "warning" : "live", remainingMs, expiresAt } } export interface UseSessionStatusOptions { /** How long before the end the warning starts. Default two minutes. */ warnMs?: number /** The shared one-second clock unless given another. */ clock?: Clock } /** The session's phase and time left, ticking once a second on the shared clock. */ export function useSessionStatus(expiresAt: number | null | undefined, options: UseSessionStatusOptions = {}): SessionStatusValue { const now = useNow(options.clock) const warnMs = options.warnMs ?? DEFAULT_WARN_MS return useMemo(() => sessionStatus(expiresAt, now, warnMs), [expiresAt, now, warnMs]) } function phaseWord(phase: SessionPhase, labels: SessionGuardLabels): string { return phase === "live" ? labels.live : phase === "warning" ? labels.ending : phase === "expired" ? labels.ended : labels.noSession } interface RequestState { pending: boolean failed: boolean } function createRequestStore(authenticate: () => Promise<boolean>) { let state: RequestState = { pending: false, failed: false } let phase: SessionPhase | undefined let needsNotification = false let active = false let generation = 0 const listeners = new Set<() => void>() function notify() { if (!needsNotification) return needsNotification = false for (const listener of listeners) listener() } function publish(next: RequestState) { if (state.pending === next.pending && state.failed === next.failed) return state = next needsNotification = true notify() } const recovering = (next: SessionPhase) => next !== phase && (next === "live" || next === "none") return { subscribe(listener: () => void) { listeners.add(listener) return () => { listeners.delete(listener) } }, snapshot: () => state, recovering, commitPhase(next: SessionPhase) { // Commit before child layout commands, without scheduling React work in insertion effects. if (recovering(next) && state.failed) { state = { ...state, failed: false } needsNotification = true } phase = next }, notify, setCallback(next: () => Promise<boolean>) { authenticate = next }, mount() { active = true }, unmount() { active = false generation++ state = { pending: false, failed: false } needsNotification = false }, async reauthenticate() { if (!active || state.pending) return const request = generation // Lock before calling application code, including another control in this same event. publish({ pending: true, failed: false }) let ok = false try { ok = await authenticate() } catch { /* A thrown or rejected request is a refusal. */ } if (active && request === generation) publish({ pending: false, failed: !ok }) }, } } interface SessionContextValue { phase: SessionPhase expiresAt: number | null warnMs: number clock: Clock labels: SessionGuardLabels request: ReturnType<typeof createRequestStore> } const SessionContext = createContext<SessionContextValue | null>(null) function useSessionContext() { const context = useContext(SessionContext) if (!context) throw new Error("SessionGuard parts must be inside SessionGuardProvider.") return context } export interface SessionGuardProviderProps extends UseSessionStatusOptions { /** When the session ends, ms since the epoch. Non-finite or absent values mean no session. */ expiresAt: number | null | undefined /** Request a new session. The result does not change expiresAt. */ onReauthenticate: () => Promise<boolean> /** Caller-owned warnings, dialogs, controls and surrounding content. */ children: ReactNode /** Called by an effect on entering expired, including an expired mount and effect replay. */ onExpire?: () => void labels?: Partial<SessionGuardLabels> } /** Coordinate one session without owning its markup. Plain clock ticks do not render its children. */ export function SessionGuardProvider({ expiresAt: suppliedExpiry, warnMs = DEFAULT_WARN_MS, onReauthenticate, onExpire, labels: labelsProp, clock: suppliedClock, children }: SessionGuardProviderProps) { const clock = suppliedClock ?? sharedClock() const expiresAt = suppliedExpiry === null || suppliedExpiry === undefined || !Number.isFinite(suppliedExpiry) ? null : suppliedExpiry const phaseSnapshot = useCallback(() => sessionStatus(expiresAt, clock.now(), warnMs).phase, [expiresAt, clock, warnMs]) // Subscribed through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribeClock = useCallback((cb: () => void) => clock.subscribe(cb), [clock]) const phase = useSyncExternalStore(subscribeClock, phaseSnapshot, phaseSnapshot) const labels = useMemo<SessionGuardLabels>(() => ({ ...DEFAULT_SESSION_GUARD_LABELS, ...labelsProp }), [labelsProp]) const [request] = useState(() => createRequestStore(onReauthenticate)) const expire = useRef(onExpire) // Publish callbacks and ownership before a descendant can invoke a command in a layout effect. useInsertionEffect(() => { request.setCallback(onReauthenticate) request.commitPhase(phase) expire.current = onExpire }, [request, phase, onReauthenticate, onExpire]) useInsertionEffect(() => { request.mount() return () => request.unmount() }, [request]) useLayoutEffect(() => request.notify(), [phase, request]) useEffect(() => { if (phase === "expired") expire.current?.() }, [phase]) const context = useMemo(() => ({ phase, expiresAt, warnMs, clock, labels, request }), [phase, expiresAt, warnMs, clock, labels, request]) return <SessionContext value={context}>{children}</SessionContext> } export interface SessionGuardValue extends Omit<SessionContextValue, "request">, RequestState { /** One shared request, ignored while pending or after the provider unmounts. */ reauthenticate: () => Promise<void> } /** Shared phase, settings and sign-in state. Time readings use useSessionStatus or SessionGuardRemaining. */ export function useSessionGuard(): SessionGuardValue { const { request, ...context } = useSessionContext() const state = useSyncExternalStore(request.subscribe, request.snapshot, request.snapshot) return { ...context, ...state, failed: state.failed && !request.recovering(context.phase), reauthenticate: request.reauthenticate } } export interface SessionGuardWarningProps extends ComponentProps<"div"> { children: ReactNode /** Where focus goes when the banner closes with focus inside it, as when its own action renews the session. Without it, focus goes back where it came from. */ fallbackFocusRef?: RefObject<HTMLElement | null> } /** Caller-owned warning content, mounted only inside the warning window. */ export function SessionGuardWarning(props: SessionGuardWarningProps) { const { phase } = useSessionContext() return phase === "warning" ? <WarningBanner {...props} /> : null } // The banner removes itself when the session renews, often from its own button, and focus inside it would fall to // the page: it goes to the fallback, or back where it came from into the banner. function WarningBanner({ className, role = "status", fallbackFocusRef, ref, onFocus, ...props }: SessionGuardWarningProps) { const node = useRef<HTMLDivElement | null>(null) const cameFrom = useRef<HTMLElement | null>(null) const fallback = useRef(fallbackFocusRef) useInsertionEffect(() => { fallback.current = fallbackFocusRef }, [fallbackFocusRef]) const bannerRef = useCallback((element: HTMLDivElement | null) => { node.current = element if (typeof ref === "function") return ref(element) if (ref) ref.current = element }, [ref]) // A layout cleanup runs while the node is still in the document, so it can tell whether focus was inside it. The move // waits a microtask and needs the banner gone with focus fallen to the page: StrictMode's replay runs this cleanup on a // banner that stays, and focus another part has placed meanwhile is that part's. useLayoutEffect(() => () => { const banner = node.current if (!banner) return const focused = (banner.getRootNode() as Document | ShadowRoot).activeElement if (!focused || !banner.contains(focused)) return const doc = banner.ownerDocument queueMicrotask(() => { if (banner.isConnected || (doc.activeElement && doc.activeElement !== doc.body)) return for (const target of [fallback.current?.current, cameFrom.current]) { // A target in another document can't take this page's focus back. if (!target?.isConnected || target.ownerDocument !== doc || banner.contains(target) || target.matches(":disabled") || target.closest("[inert], [hidden], [aria-hidden='true']")) continue target.focus() // One the browser can't focus, hidden by a style, say, leaves focus on the page: try the next. Focus the target // hands on to something inside it counts. const now = (target.getRootNode() as Document | ShadowRoot).activeElement if (now && target.contains(now)) return } }) }, []) return <div role={role} data-slot="tradecn-session-guard" data-session-banner="" className={cn("flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-expiring/50 bg-expiring-soft px-3 py-1.5 text-xs lining-nums tabular-nums text-foreground [&_.text-up]:text-inherit [&_.text-down]:text-inherit [&_.text-flat]:text-inherit [&_.text-stale]:text-inherit [&_.text-expiring]:text-inherit [&_.text-destructive]:text-inherit [&_.text-primary]:text-inherit [&_.text-muted-foreground]:text-inherit", className)} {...props} ref={bannerRef} onFocus={(event) => { onFocus?.(event) // Arriving from outside, focus came from that element, or from nowhere the banner can send it back to. const from = event.relatedTarget as HTMLElement | null if (!event.currentTarget.contains(from)) cameFrom.current = from?.nodeType === 1 ? from : null }} /> } /** The provider's countdown. Only this reading renders on ordinary clock ticks. */ export function SessionGuardRemaining(props: Omit<ComponentProps<"span">, "children">) { const { expiresAt } = useSessionContext() return expiresAt === null ? null : <RemainingTime {...props} expiresAt={expiresAt} /> } function RemainingTime({ expiresAt, className, role = "timer", "aria-label": label, ...props }: Omit<ComponentProps<"span">, "children"> & { expiresAt: number }) { const { warnMs, clock, labels } = useSessionContext() const now = useNow(clock) const remaining = expiresAt - now const tier = countdownTier(remaining, { soonMs: warnMs }) return <span role={role} aria-label={label ?? (props["aria-labelledby"] ? undefined : labels.session)} data-slot="tradecn-session-guard-remaining" data-tier={tier} className={cn("inline-flex items-baseline text-xs lining-nums tabular-nums", tier === "soon" ? "font-semibold text-expiring" : tier === "expired" ? "text-muted-foreground" : "text-foreground", className)} {...props}> <span data-countdown-digits data-numeric="">{formatRemaining(remaining)}</span> </span> } /** The localized warning sentence with its ticking reading. Compose Remaining directly for other layouts. */ export function SessionGuardWarningText(props: Omit<ComponentProps<"span">, "children">) { const { labels } = useSessionContext() const [before, after] = labels.warning.split("{remaining}") return <span {...props}>{before}<SessionGuardRemaining />{after}</span> } export interface SessionGuardReauthenticateProps extends Omit<ComponentProps<typeof Button>, "children"> { children: ReactNode } /** * An installed button sharing the provider's request lock. preventDefault cancels the request. While a request is out * it stays focusable with `aria-disabled` and a press does nothing: a disabled button loses focus to the page, and the * banner it sits in closes, or the request is refused, with no focus left to send back. */ export function SessionGuardReauthenticate({ children, disabled, type = "button", onClick, className, ...props }: SessionGuardReauthenticateProps) { const { pending, reauthenticate } = useSessionGuard() return <Button data-slot="tradecn-session-guard-reauthenticate" data-pending={pending || undefined} {...props} aria-disabled={pending || props["aria-disabled"] || undefined} className={cn("aria-disabled:opacity-50", className)} type={type} disabled={disabled} onClick={event => { // Held, the press goes no further, as a disabled button's would: nothing above it hears the click either. if (pending) { event.preventDefault() event.stopPropagation() return } onClick?.(event) if (!event.defaultPrevented) void reauthenticate() }}>{children}</Button> } /** The pending text, warning action or expired action, using the provider's labels. */ export function SessionGuardActionLabel(props: Omit<ComponentProps<"span">, "children">) { const { phase, pending, labels } = useSessionGuard() return <span {...props}>{pending ? labels.pending : phase === "warning" ? labels.extend : labels.reauthenticate}</span> } /** The shared refusal, mounted as an alert until the next attempt or recovery. */ export function SessionGuardError({ className, children, role = "alert", ...props }: ComponentProps<"span">) { const { failed, labels } = useSessionGuard() if (!failed) return null return <span role={role} data-slot="tradecn-session-guard-error" data-session-failed="" className={cn("text-xs text-destructive", className)} {...props}>{children === undefined ? labels.failed : children}</span> } export interface SessionGuardDialogProps extends Omit<ComponentProps<typeof DialogContent>, "children" | "showCloseButton" | "finalFocus" | "onCloseAutoFocus" | "forceMount"> { children: ReactNode /** A persistent application control, used only if the primitive leaves focus in the closing dialog or on body. */ fallbackFocusRef: RefObject<HTMLElement | null> open?: never defaultOpen?: never onOpenChange?: never modal?: never showCloseButton?: never finalFocus?: never onCloseAutoFocus?: never forceMount?: never keepMounted?: never } // Only the supplied session closes the modal. function keepOpen() {} /** Expiry-controlled modal content. Callers own its heading, form and actions; the wall owns dismissal and fallback focus. */ export function SessionGuardDialog({ fallbackFocusRef, children, className, ref, ...props }: SessionGuardDialogProps) { const { phase } = useSessionContext() const open = phase === "expired" const wasOpen = useRef(false) const content = useRef<HTMLElement | null>(null) const contentRef = useCallback((node: HTMLDivElement | null) => { // Keep the closing node until its exit completes, even when initial focus is customized. if (node) content.current = node if (typeof ref === "function") return ref(node) if (ref) ref.current = node }, [ref]) const fallback = useRef(fallbackFocusRef) useInsertionEffect(() => { fallback.current = fallbackFocusRef }, [fallbackFocusRef]) useLayoutEffect(() => { if (open) { wasOpen.current = true return } if (!wasOpen.current) return wasOpen.current = false const closingContent = content.current const owner = closingContent?.ownerDocument ?? fallback.current.current?.ownerDocument if (!owner) return let timer: ReturnType<typeof setTimeout> | undefined const restore = () => { content.current = null const target = fallback.current.current if (!owner.hasFocus() || !target?.isConnected || target.ownerDocument !== owner) return const focused = owner.activeElement if (focused !== owner.body && focused !== null && !closingContent?.contains(focused)) return if (target.matches(":disabled") || target.closest("[inert], [hidden], [aria-hidden='true']") || !target.getClientRects().length) return const visibility = owner.defaultView?.getComputedStyle(target).visibility if (visibility === "hidden" || visibility === "collapse") return target.focus() } // Exit animations can keep the popup mounted and the desk inert after open becomes false. // Wait for actual removal, then let the installed primitive finish its native focus cleanup. const Observer = owner.defaultView?.MutationObserver const observer = Observer && closingContent?.isConnected ? new Observer(() => { if (closingContent.isConnected) return observer?.disconnect() timer = setTimeout(restore, 0) }) : undefined if (observer) observer.observe(owner.documentElement, { childList: true, subtree: true }) else timer = setTimeout(restore, 0) return () => { observer?.disconnect() clearTimeout(timer) } }, [open]) return <Dialog open={open} onOpenChange={keepOpen} modal> <DialogContent {...props} ref={contentRef} showCloseButton={false} data-session-dialog="" className={cn("max-h-[calc(100%-2rem)] overflow-auto sm:max-w-md", className)}>{children}</DialogContent> </Dialog> } export interface SessionStatusProps { expiresAt: number | null | undefined warnMs?: number clock?: Clock labels?: Partial<SessionGuardLabels> className?: string } /** The session for a status bar: the word, the time left, and the phase on `data-session-status`, spoken through visually hidden text. */ export function SessionStatus({ expiresAt, warnMs = DEFAULT_WARN_MS, clock, labels: labelsProp, className }: SessionStatusProps) { const labels = useMemo<SessionGuardLabels>(() => ({ ...DEFAULT_SESSION_GUARD_LABELS, ...labelsProp }), [labelsProp]) const status = useSessionStatus(expiresAt, { warnMs, clock }) const remaining = status.remainingMs !== null && status.remainingMs > 0 ? formatRemaining(status.remainingMs) : null const word = phaseWord(status.phase, labels) return ( <span data-session-status={status.phase} className={cn("inline-flex items-center gap-1 whitespace-nowrap", status.phase === "warning" && "text-expiring", status.phase === "expired" && "text-destructive", className)} > {/* A generic span cannot carry aria-label, so the spoken sentence is hidden text. */} <span className="sr-only">{`${labels.session}: ${word}${remaining === null ? "" : `, ${remaining}`}`}</span> <span aria-hidden>{labels.session}</span>{" "} {status.phase === "expired" ? ( <span aria-hidden>{labels.ended}</span> ) : remaining !== null ? ( <span aria-hidden data-numeric="" className={NUMERIC_CLASS}> {remaining} </span> ) : ( <span aria-hidden className="text-muted-foreground"> {labels.noSession} </span> )} </span> ) }components/ui/ countdown.tsx import { cn } from "cn" import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react" import { useNow } from "@/hooks/use-clock" import { sharedClock, type Clock } from "@/lib/clock" // Time left to a moment: digits that tick once a second and a bar that shrinks to nothing. // // The digits read the shared clock, so a stack of forty countdowns costs one interval. The bar is // one Web Animation on its element, started once and left to the compositor, so it costs nothing per // frame; under reduced motion it steps with the digits instead. The tier turns in the last seconds // and stops at zero. What the countdown never does is decide anything: it shows the time it was // given, and the server's own word is what says whether the thing it timed is over. export type CountdownTier = "plenty" | "soon" | "expired" export interface CountdownThresholds { /** From how far out the tier is `soon`. */ soonMs: number } /** A placeholder until the people who work the screen say how many seconds count as soon. */ export const PROVISIONAL_COUNTDOWN_THRESHOLDS: CountdownThresholds = { soonMs: 10_000 } /** Pure tiering. At or below zero is expired, whatever the thresholds. */ export function countdownTier(remainingMs: number, thresholds: CountdownThresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS): CountdownTier { if (!(remainingMs > 0)) return "expired" return remainingMs <= thresholds.soonMs ? "soon" : "plenty" } /** "1:30", "0:09", "0:00", and "1:02:03" past an hour. Rounds up, so it never says zero while time is left: an endless wait (`Infinity`) prints "–". */ export function formatRemaining(ms: number): string { if (ms === Infinity) return "–" const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0 const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 const minutes = h > 0 ? String(m).padStart(2, "0") : String(m) return `${h > 0 ? `${h}:` : ""}${minutes}:${String(s).padStart(2, "0")}` } const TIER_CLASS: Record<CountdownTier, string> = { plenty: "text-foreground", soon: "font-semibold text-expiring", expired: "text-muted-foreground", } const BAR_CLASS: Record<CountdownTier, string> = { plenty: "bg-primary", soon: "bg-expiring", expired: "bg-muted-foreground", } function prefersReducedMotion(): boolean { return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches } export interface CountdownProps { /** When it ends, in ms since the epoch. */ expiresAt: number /** When it began, for the bar's full width. Left out, the moment the countdown was first drawn. */ startsAt?: number thresholds?: CountdownThresholds clock?: Clock /** The name a screen reader gives it, and the subject of its announcements. Default "Time left". */ label?: string /** The digits alone, inline, for a grid cell. */ compact?: boolean /** Speak the time left when the tier changes, in a polite live region. Default true; turn it off in a grid, where the stack speaks. */ announce?: boolean /** Called once when the digits reach zero. A display event: the server's word says whether the thing timed is over. */ onExpire?: () => void className?: string } // The capability never changes within a session, so the store is three constants. const subscribeToNothing = () => () => {} const hasWebAnimations = () => typeof Element !== "undefined" && typeof Element.prototype.animate === "function" const optimistic = () => true export function Countdown({ expiresAt, startsAt, thresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS, clock, label = "Time left", compact = false, announce = true, onExpire, className }: CountdownProps) { const c = clock ?? sharedClock() const ticked = useNow(c) // First sight samples the time source directly: the shared clock's last tick can be // arbitrarily old when nothing subscribed while this countdown was away. The time is never // behind first sight, so the first frame shows the real time left and nothing is announced // when the clock catches up. const [firstSeen] = useState(() => c.sample?.() ?? ticked) const now = Math.max(ticked, firstSeen) const remaining = expiresAt - now const tier = countdownTier(remaining, thresholds) // The bar's full width is `startsAt` to `expiresAt`; without a finite `startsAt`, from first sight to the end. // With no finite deadline it does not move: full for an endless wait, empty for one it cannot read. const total = Math.max(1, expiresAt - (typeof startsAt === "number" && Number.isFinite(startsAt) ? startsAt : firstSeen)) const fraction = Number.isFinite(total) ? Math.max(0, Math.min(1, remaining / total)) : remaining > 0 ? 1 : 0 const [reduced] = useState(prefersReducedMotion) // A browser without Web Animations draws the bar from the digits' tick too, instead of holding it // full. The server snapshot is optimistic so hydration agrees, and the client reads the real API. const canAnimate = useSyncExternalStore(subscribeToNothing, hasWebAnimations, optimistic) // Drawn from the digits' tick when it cannot animate: reduced motion, no API, nothing left to animate, or no deadline to run to. const staticBar = reduced || !canAnimate || tier === "expired" || !Number.isFinite(expiresAt) const bar = useRef<HTMLSpanElement>(null) useLayoutEffect(() => { const el = bar.current if (!el || staticBar || typeof el.animate !== "function") return const left = Math.max(0, expiresAt - (c.sample?.() ?? c.now())) const from = Math.max(0, Math.min(1, left / total)) // One animation for exactly the time left, linear, held at zero when it ends. The // compositor runs it. When the fresh sample says time is already up while the last tick // has not caught up — the second right after a deadline — the zero-length run holds the // bar empty instead of leaving it full until the tick. const animation = el.animate([{ transform: `scaleX(${from})` }, { transform: "scaleX(0)" }], { duration: left, easing: "linear", fill: "forwards" }) return () => animation.cancel() }, [expiresAt, total, c, staticBar]) // Said once, when the digits reach zero; said again only if the end moves out and comes back. const latest = useRef(onExpire) useEffect(() => { latest.current = onExpire }) const fired = useRef(false) useEffect(() => { if (tier !== "expired") { fired.current = false return } if (fired.current) return fired.current = true latest.current?.() }, [tier]) // Derived state, set during render: speak when the tier changes, never on a plain tick. const [seen, setSeen] = useState(tier) const [message, setMessage] = useState("") if (seen !== tier) { setSeen(tier) setMessage(`${label} ${formatRemaining(remaining)}`) } // Named by the label and the time left together, so the digits reach a screen reader wherever the name is read, // a grid row's name built from its cells included. const id = useId() return ( <span role="timer" aria-labelledby={`${id}-label ${id}-digits`} data-slot="tradecn-countdown" data-tier={tier} // Soon, the full countdown sits on the expiring tint with foreground digits, which read there in every theme. className={cn(compact ? "inline-flex items-baseline" : "inline-flex min-w-16 flex-col gap-0.5 rounded px-1", "text-xs lining-nums tabular-nums", TIER_CLASS[tier], tier === "soon" && !compact && "bg-expiring-soft text-foreground", className)} > <span id={`${id}-label`} hidden> {label} </span> <span id={`${id}-digits`} data-countdown-digits data-numeric=""> {formatRemaining(remaining)} </span> {!compact && ( <span aria-hidden className="block h-1 w-full overflow-hidden rounded-full bg-muted"> <span ref={bar} className={cn("block h-full w-full origin-left", BAR_CLASS[tier])} style={staticBar ? { transform: `scaleX(${fraction})` } : undefined} /> </span> )} {announce && ( <span aria-live="polite" className="sr-only"> {message} </span> )} </span> ) }hooks/use-clock.ts import { useCallback, useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() // Called through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribe = useCallback((cb: () => void) => c.subscribe(cb), [c]) const now = useCallback(() => c.now(), [c]) return useSyncExternalStore(subscribe, now, now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number /** Read the source for an event timestamp without changing the tick snapshot or starting a timer. */ sample?(): number } /** One interval shared by every subscriber; it runs only while someone is listening. An interval that is not a positive number of milliseconds a timer can hold, up to 2^31 − 1, ticks once a second. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { // A zero, negative, NaN, or overlong interval (a timer holds 2^31 − 1 ms and fires at once past it) would run as // fast as the timer allows and re-render every subscriber each time. const every = intervalMs > 0 && intervalMs <= 2_147_483_647 ? intervalMs : 1000 let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, sample: source, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, every) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } .dark { --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { cn } from "cn"
import { useRef, useState, type ReactNode, type RefObject } from "react"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { SessionGuardActionLabel, SessionGuardDialog, SessionGuardError, SessionGuardProvider, SessionGuardReauthenticate, SessionGuardWarning, SessionGuardWarningText, useSessionGuard, type SessionGuardProviderProps } from "@/components/ui/session-guard"
export default function SessionGuardDemo() {
const [expiresAt, setExpiresAt] = useState(() => Date.now() + 30_000)
const [note, setNote] = useState("")
const draft = useRef<HTMLInputElement>(null)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Expire session</button>
</div>
<div className="flex min-h-72 w-sm max-w-full flex-col justify-center gap-3 text-xs">
<SessionNotice expiresAt={expiresAt} warnMs={60_000} fallbackFocusRef={draft} onReauthenticate={async () => {
setExpiresAt(Date.now() + 300_000)
return true
}}>
<p className="text-sm text-muted-foreground">This example signs in immediately.</p>
</SessionNotice>
<label className="flex flex-col gap-1.5">
Draft note
<input ref={draft} className="rounded border border-border bg-background px-3 py-2" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Kept through sign-in" />
</label>
</div>
</>
)
}
export interface SessionNoticeProps extends Omit<SessionGuardProviderProps, "children"> {
children?: ReactNode
className?: string
fallbackFocusRef: RefObject<HTMLElement | null>
}
export function SessionNotice({ children, className, fallbackFocusRef, ...props }: SessionNoticeProps) {
return <SessionGuardProvider {...props}><SessionNoticeContent className={className} fallbackFocusRef={fallbackFocusRef}>{children}</SessionNoticeContent></SessionGuardProvider>
}
function SessionNoticeContent({ children, className, fallbackFocusRef }: Pick<SessionNoticeProps, "children" | "className" | "fallbackFocusRef">) {
const { phase, labels, pending } = useSessionGuard()
return (
<div data-session-phase={phase} className={cn(phase === "warning" ? "block" : "contents", className)}>
<SessionGuardWarning fallbackFocusRef={fallbackFocusRef}>
<SessionGuardWarningText />
<SessionGuardReauthenticate size="sm" variant="outline" className="h-7"><SessionGuardActionLabel /></SessionGuardReauthenticate>
<SessionGuardError />
</SessionGuardWarning>
<SessionGuardDialog fallbackFocusRef={fallbackFocusRef}>
<DialogHeader>
<DialogTitle>{labels.expiredTitle}</DialogTitle>
<DialogDescription>{labels.expiredDescription}</DialogDescription>
</DialogHeader>
{children}
<div className="flex flex-wrap items-center gap-2">
<SessionGuardReauthenticate>{pending ? labels.pending : labels.reauthenticate}</SessionGuardReauthenticate>
<SessionGuardError />
</div>
</SessionGuardDialog>
</div>
)
}
Save this example as session-guard.tsx, outside components/ui so it cannot overwrite the installed component, beside examples that import SessionNotice. It uses the Button, Dialog and cn dependencies included by the session-guard installation. Keep application drafts outside conditional warning and dialog content.
Your application owns authentication. Await your session service, update expiresAt on success, and return its boolean result from onReauthenticate. Returning true alone does not close the dialog.
Composition
Use the following composition to build a SessionGuardProvider:
SessionGuardProvider
├── SessionGuardWarning
│ ├── SessionGuardWarningText
│ │ └── SessionGuardRemaining
│ ├── SessionGuardReauthenticate
│ │ └── SessionGuardActionLabel
│ └── SessionGuardError
└── SessionGuardDialog
├── DialogHeader
│ ├── DialogTitle
│ └── DialogDescription
├── Application sign-in content
├── SessionGuardReauthenticate
└── SessionGuardError
The provider adds no markup. Arrange the warning, readings, actions and sign-in content yourself, or reuse SessionNotice from Usage. SessionGuardRemaining places the countdown without the default warning sentence; useSessionGuard supplies the same request behavior to custom controls.
Custom layout
Use SessionGuardRemaining and useSessionGuard to move the reading and replace the sign-in controls.
import { useRef, useState } from "react"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { SessionGuardDialog, SessionGuardError, SessionGuardProvider, SessionGuardRemaining, SessionGuardWarning, useSessionGuard } from "@/components/ui/session-guard"
export default function SessionGuardInlineDemo() {
const [expiresAt, setExpiresAt] = useState(() => Date.now() + 45_000)
const [note, setNote] = useState("")
const draft = useRef<HTMLTextAreaElement>(null)
return (
<SessionGuardProvider expiresAt={expiresAt} onReauthenticate={async () => { setExpiresAt(Date.now() + 300_000); return true }}>
<div className="w-sm max-w-full space-y-3 text-xs">
<label className="flex flex-col gap-1.5">Desk note<textarea ref={draft} value={note} onChange={event => setNote(event.target.value)} className="min-h-20 rounded border border-input bg-background p-2" /></label>
<SessionGuardWarning fallbackFocusRef={draft} className="grid gap-3 p-3">
<p className="font-medium">Trading desk session</p>
<div className="flex flex-wrap items-center justify-between gap-3"><RenewSession /><SessionGuardRemaining /></div>
<SessionGuardError />
</SessionGuardWarning>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Lock desk</button>
</div>
<SessionGuardDialog fallbackFocusRef={draft} className="sm:max-w-xl sm:grid-cols-2">
<DialogHeader><DialogTitle>Unlock this desk</DialogTitle><DialogDescription>Your draft stays here while you sign in.</DialogDescription></DialogHeader>
<div className="space-y-3"><p className="text-sm">This example renews the session immediately.</p><SessionGuardError /><RenewSession /></div>
</SessionGuardDialog>
</SessionGuardProvider>
)
}
function RenewSession() {
const { pending, reauthenticate } = useSessionGuard()
return <button type="button" aria-disabled={pending || undefined} onClick={() => { if (!pending) void reauthenticate() }} className="rounded border border-border bg-background px-3 py-1.5 aria-disabled:opacity-50">{pending ? "Connecting…" : "Renew session"}</button>
}
Pending and refused sign-ins
Keep the action pending until the simulated service accepts or refuses the request. Copy session-guard.tsx from Usage into the same directory first; this example imports its SessionNotice composition.
import { useEffect, useRef, useState } from "react"
import { SessionStatus } from "@/components/ui/session-guard"
import { SessionNotice } from "./session-guard"
export default function SessionGuardRepliesDemo() {
const [expiresAt, setExpiresAt] = useState<number | null>(null)
const [waiting, setWaiting] = useState(false)
const opener = useRef<HTMLButtonElement>(null)
const reply = useRef<((ok: boolean) => void) | null>(null)
useEffect(() => () => {
reply.current?.(false)
reply.current = null
}, [])
function answer(ok: boolean) {
const resolve = reply.current
if (!resolve) return
reply.current = null
if (ok) setExpiresAt(Date.now() + 300_000)
setWaiting(false)
resolve(ok)
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button ref={opener} type="button" className="rounded border border-border px-2 py-1" onClick={() => setExpiresAt(0)}>Show expired session</button>
</div>
<div className="flex min-h-96 w-fit max-w-full items-center justify-center text-xs">
<SessionStatus expiresAt={expiresAt} warnMs={0} />
<SessionNotice fallbackFocusRef={opener} expiresAt={expiresAt} warnMs={0} onReauthenticate={() => new Promise<boolean>((resolve) => {
reply.current = resolve
setWaiting(true)
})}>
<fieldset className="space-y-2 text-xs" disabled={!waiting}>
<legend className="mb-2">Simulated identity provider</legend>
<p>Choose Sign in again, then send its reply.</p>
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" onClick={() => answer(true)}>Accept sign-in</button>
<button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" onClick={() => answer(false)}>Refuse sign-in</button>
</div>
</fieldset>
</SessionNotice>
</div>
</>
)
}
Session status readouts
Use SessionStatus independently of a provider. This comparison supplies a fixed clock; live readouts use the shared clock by default. Render the guard on the client: the shared clock advances only while something in a browser subscribes, so a server can render one phase while the client computes another, a structural hydration mismatch. Install StatusBar separately to place a readout in its children.
import type { Clock } from "@/lib/clock"
import { SessionStatus } from "@/components/ui/session-guard"
const NOW = Date.UTC(2026, 8, 23, 12)
const clock: Clock = { now: () => NOW, subscribe: () => () => {} }
const sessions = [
{ name: "Live", expiresAt: NOW + 300_000 },
{ name: "Warning", expiresAt: NOW + 30_000 },
{ name: "Expired", expiresAt: NOW },
{ name: "No session", expiresAt: null },
]
export default function SessionGuardStatusDemo() {
return (
<table className="w-fit max-w-full text-xs lining-nums tabular-nums">
<caption className="caption-bottom pt-3 text-muted-foreground">Fixed clock for comparison</caption>
<thead><tr><th scope="col" className="pb-2 pr-6 text-left font-normal">Phase</th><th scope="col" className="pb-2 text-left font-normal">Readout</th></tr></thead>
<tbody>{sessions.map(({ name, expiresAt }) => (
<tr key={name} className="border-t border-border">
<th scope="row" className="py-2 pr-6 text-left font-normal">{name}</th>
<td className="py-2"><SessionStatus expiresAt={expiresAt} clock={clock} /></td>
</tr>
))}</tbody>
</table>
)
}
API Reference
Props
SessionGuardProvider coordinates one expiry and one request. It requires composition through children and has no native element, className or ref.
| Prop | Type | Default | Description |
|---|---|---|---|
expiresAt |
number | null | undefined |
Required | Expiry in epoch milliseconds. Null, undefined and non-finite numbers mean no session. |
onReauthenticate |
() => Promise<boolean> |
Required | Request a session; the result does not change the expiry. |
children |
ReactNode |
Required | Warnings, dialogs, controls and application content. |
warnMs |
number |
120_000 |
Warning window in milliseconds, exported as DEFAULT_WARN_MS. |
onExpire |
() => void |
- | Effect notification on entering expired, including an expired mount. |
labels |
Partial<SessionGuardLabels> |
Default labels | Text used by readings and the supplied composition. |
clock |
Clock |
sharedClock() from @/lib/clock |
Shared clock for phase and remaining-time readings; createClock lives beside it. |
Changing an expired timestamp to another expired timestamp, or replacing onExpire while still expired, does not notify again. Leaving and reentering expired does. Remounts and development Strict Mode effect replay can repeat the callback.
Parts
All rendered parts accept the native props, ref, classes and events of the element listed below. Classes merge with defaults. The action and dialog use the corresponding installed shadcn component's props, subject to the owned behavior below.
| Part | Element | Children | Description |
|---|---|---|---|
SessionGuardWarning |
div |
Required | Warning-only status region with wrapping strip styles. fallbackFocusRef takes the element to focus when the banner closes with focus inside it; without it, focus goes back where it came from. |
SessionGuardWarningText |
span |
Not accepted | Localized warning sentence containing SessionGuardRemaining. |
SessionGuardRemaining |
span |
Not accepted | Timer reading using the provider's expiry, warning window and clock. |
SessionGuardReauthenticate |
Installed Button |
Required | Shared sign-in action; defaults to type="button". |
SessionGuardActionLabel |
span |
Not accepted | Pending, extension or sign-in text for an action. |
SessionGuardError |
span |
Optional | Failure alert; omitted children use labels.failed. |
SessionGuardDialog |
Installed DialogContent |
Required | Expiry-controlled modal content. Requires fallbackFocusRef. |
The warning carries data-slot="tradecn-session-guard" and data-session-banner. Remaining, action and error use the slots tradecn-session-guard-remaining, tradecn-session-guard-reauthenticate and tradecn-session-guard-error. The dialog has data-session-dialog; the error has data-session-failed. SessionNotice places data-session-phase on its own wrapper.
useSessionGuard
Call useSessionGuard() inside the provider for custom controls and layouts. It returns SessionGuardValue:
| Field | Type | Description |
|---|---|---|
phase |
SessionPhase |
Current session phase. |
expiresAt |
number | null |
Normalized expiry. |
warnMs |
number |
Configured warning window. |
clock |
Clock |
Clock shared by the provider and its readings. |
labels |
SessionGuardLabels |
Merged labels. |
pending / failed |
boolean |
State shared by every action in the provider. |
reauthenticate |
() => Promise<void> |
Start a request; ignored while pending or after provider unmount. |
A duplicate call resolves without waiting for the active request. Read pending and failed to display request state. The hook does not subscribe to remaining milliseconds: ordinary ticks update only SessionGuardRemaining and standalone time hooks or readouts.
Phases
sessionStatus and useSessionStatus retain the standalone SessionStatusValue contract:
| API | Inputs | Result |
|---|---|---|
sessionStatus(expiresAt, now, warnMs?) |
Expiry, epoch milliseconds, optional warning window | Status at the supplied time. |
useSessionStatus(expiresAt, options?) |
Expiry and optional UseSessionStatusOptions |
Status updated on clock ticks. Options are warnMs?: number and clock?: Clock. |
| Field | Type | Meaning |
|---|---|---|
phase |
SessionPhase |
"none", "live", "warning", or "expired". |
remainingMs |
number | null |
Expiry minus clock time; negative after expiry, null with no session. |
expiresAt |
number | null |
Supplied finite expiry, or null with no session. |
| Phase | When | Conditional parts |
|---|---|---|
none |
Null, undefined, NaN, or infinite expiry |
Warning and dialog hidden |
live |
More than warnMs left, above zero |
Warning and dialog hidden |
warning |
warnMs or less left, above zero |
Warning mounted |
expired |
Zero or less left | Dialog open |
warnMs is compared directly; zero or negative values skip warning. Both APIs default to DEFAULT_WARN_MS and the hook uses the shared one-second clock from lib/clock, which countdown uses too. For tests, pass createClock(1000, () => t) and advance both t and the timer.
The banner
SessionGuardWarning supplies role="status" and the expiring and expiring-soft strip styles. Text inside it, the remaining time and a refusal included, is the foreground, since a state color on the expiring tint falls below 4.5 to 1 in light mode. The banner leaves when the session renews, often from its own button, so focus inside it moves to fallbackFocusRef, or back to the element it came from, instead of falling to the page. A target that is gone, disabled, or can't take focus is skipped for the next. Focus moves only once the banner has left the page and focus has fallen to it, so focus the banner's own content takes as it mounts, under StrictMode's replay too, or that another part places as it closes, stays where it is. Place optional controls, account information and errors in its children. SessionGuardWarningText inserts the remaining-time reading into labels.warning.
SessionGuardRemaining uses the same rounded-up countdown format as Countdown, including data-tier and numeric typography. It has role="timer" and the session label, with no separate live region. Place one warning sentence per session to avoid duplicate announcements.
The wall
SessionGuardDialog owns a modal Dialog and its content. It opens when expired, has no close button, and ignores Escape and outside close requests. Renewing to a future expiry or clearing the expiry closes it. Content scrolls within the viewport by default; use className for another width or layout.
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
Required | A heading, description, sign-in content and actions. |
fallbackFocusRef |
RefObject<HTMLElement | null> |
Required | Persistent, focusable application control to receive focus if native restoration leaves it on the body or closing dialog. |
className |
string |
- | Additional classes to apply to the dialog content. |
Use the installed DialogTitle and DialogDescription to name and describe the dialog. Keep the fallback target mounted outside conditional content. A valid native return target takes precedence. The part owns final focus, so the focus-return and mount-control props are reserved whatever your base calls them: onCloseAutoFocus and forceMount on Radix, finalFocus and keepMounted on Base UI, along with root control props and showCloseButton. Use useSessionGuard when replacing the whole dialog and its focus policy.
The modal blocks pointer interaction underneath. For events inside its role="dialog", use-hotkeys considers only scopes declared inside that dialog. Surrounding drafts and stores stay mounted; authentication fields inside dialog content follow the installed primitive's normal close/unmount lifecycle.
Re-authentication
Every action and custom hook control shares one request lock. While a request is out, SessionGuardReauthenticate carries aria-disabled="true" and data-pending="true", and a press does nothing and goes no further, your onClick and anything above the action included, as with a disabled button; SessionGuardActionLabel shows Signing in…. The action stays focusable, so focus stays on it until the session renews or the request is refused. A caller's disabled disables the action natively. The caller's onClick runs first and can cancel the request with preventDefault().
Hold a custom control the same way: set aria-disabled while pending and ignore its presses, rather than disabled, which moves focus to the page while the request is out.
Changing phase, expiry, clock or callback does not cancel an active request. The next attempt uses the latest committed callback. Unmounting the provider stops its old completion from updating a new provider; cancellation of application work belongs to the session service.
Resolving true clears failure without changing expiry. Resolving false, throwing or rejecting sets failure. SessionGuardError displays it as an alert wherever mounted; the supplied composition limits it to the warning or open dialog.
Starting another attempt clears failure. Entering live or none also clears a prior failure; moving between warning and expired preserves it. A request that fails after recovery can set failure again. In the ordinary composition that error becomes visible when warning or expired returns.
The status readout
SessionStatus displays Session followed by the time left (1:59:30 or 0:45), ended at expiry, or no session without a finite expiry. Remaining time rounds up to the next second. It exposes the phase through data-session-status and speaks through visually hidden text, such as Session: ending soon, 1:30, since a generic element cannot carry an accessible name of its own. Warning uses expiring; expiry uses destructive.
| Prop | Type | Default | Purpose |
|---|---|---|---|
expiresAt |
number | null | undefined |
Required | Expiry time in epoch milliseconds; the same no-session values as the guard. |
warnMs |
number |
120_000 |
Warning window in milliseconds. |
clock |
Clock |
sharedClock() |
Clock for the phase and remaining time. |
labels |
Partial<SessionGuardLabels> |
Default labels | Readout text, visible and hidden. |
className |
string |
None | Classes on the readout's span. |
Labels
The provider and standalone readout merge partial overrides into DEFAULT_SESSION_GUARD_LABELS. Every label is a string. Use one {remaining} placeholder in warning: the countdown goes between the first two parts of that split.
| Label | Default | Where |
|---|---|---|
warning |
Your session ends in {remaining}. |
Banner sentence |
extend |
Stay signed in |
Banner button |
expiredTitle / expiredDescription |
Your session has ended / Sign in again to carry on. Nothing on the desk has been lost. |
Dialog heading and description |
reauthenticate |
Sign in again |
Dialog button |
pending |
Signing in… |
Either button while pending |
failed |
That did not work. Try again. |
Alert beside either button |
session |
Session |
Readout prefix and hidden text; SessionGuardRemaining's default accessible name |
live / ending |
signed in / ending soon |
Phase words in the hidden readout text |
ended / noSession |
ended / no session |
Visible and accessible readout text |
What it does not do
The guard holds no token, reads no cookie, and does not refresh credentials or redirect. It displays the time you give it; your session service owns authentication and server authorization. It does not pause background work or enforce request permissions. Venue trading hours belong to the separate session-calendar module.
Tokens
The install adds expiring and expiring-soft if you do not have them, for the warning and the readout's warning phase, with the sans, numeric, and mono font tokens, the accessible pair, and the hyperlegible remap.
Built on shadcn's button and dialog.
This page is docs/session-guard.md at v2.0.0.