RfqTicket
A client inquiry with market and quoted levels, a countdown, and quote fields in the instrument's notation. The server supplies the status and allowed actions.
import { useState } from "react"
import { RfqTicket, describeQuote, type RfqAction, type RfqInquiry } from "@/components/rfq-ticket"
export default function RfqTicketDemo() {
const [inquiry] = useState<RfqInquiry>(() => {
const now = Date.now()
return {
id: "Q-1",
instrument: { symbol: "T10", description: "10Y Treasury", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 } },
client: { name: "ALPHA" },
side: "buy",
quantity: 5_000_000,
receivedAt: now,
expiresAt: now + 60_000,
market: { bid: 99.5, ask: 99.515625 },
status: "Open",
allowedActions: ["quote"],
}
})
const [request, setRequest] = useState("None")
const actions: RfqAction[] = [
{ id: "quote", label: "Quote", run: (draft, inquiry) => setRequest(describeQuote(draft, inquiry)) },
]
return (
<div className="w-[26rem] max-w-full space-y-2 text-xs lining-nums tabular-nums">
<RfqTicket key={inquiry.id} inquiry={inquiry} actions={actions} />
<p role="status" className="text-muted-foreground">Last request: {request}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/rfq-ticket#v1.4.13
npx shadcn@latest add tradecn/ui/rfq-ticket#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/rfq-ticket#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/rfq-ticket#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge button field input-group kbdnpx shadcn@latest add badge button field input-group kbdyarn dlx shadcn@latest add badge button field input-group kbdbunx --bun shadcn@latest add badge button field input-group kbd -
Copy the files into your project:
components/rfq-ticket.tsx import { cn } from "cn" import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useFlash } from "@/hooks/use-flash" import { HotkeyScope, useMaybeHotkeys } from "@/hooks/use-hotkeys" import { NUMERIC_CLASS, formatBps, formatNotional, formatQuantity, formatQuote, formatTicks, numericFontClass, quoteBasisOf, stepQuote, ticksBetween, type InstrumentConvention } from "@/lib/format" import { formatKeys, type HotkeyBinding, type HotkeyRegistry } from "@/lib/hotkeys" import { blocks, checkLimits, confirms, problemsByField, type Limits } from "@/lib/limits" import { Countdown } from "@/components/ui/countdown" import { QuoteField } from "@/components/ui/quote-field" // A dealer's ticket for a request for quote. A client asks for a price on a size; this shows the // inquiry as it came (who, which way, how much, in what, with the venue's words on it), the market // beside it, what is already quoted, the auto price when there is one, a countdown to its end, and // the fields to type a level in the instrument's own basis. The actions are the server's, the status // is the server's word, and nothing here decides either. // // Three rules hold it together. The ticket is one inquiry: mount it with `key={inquiry.id}` and a // new inquiry never lands in a ticket someone is working, because the parent decides which inquiry // is active and this component only ever draws the one it was given. The buttons are the actions // the server allowed, checked again as the click lands. And the status is printed as it arrives: // "Quoted" is what the venue said, never what a button press implied. export type RfqSide = "buy" | "sell" | "two-way" export type QuoteSide = "bid" | "ask" export interface RfqInstrument { symbol: string /** What the ticket prints: "T 4 1/8 05/15/34". The symbol when left out. */ description?: string /** How a quote prints, parses, and steps, and in what basis. */ convention: InstrumentConvention } export interface RfqClient { name: string tier?: string trader?: string salesperson?: string } export interface RfqLevels { bid?: number | null ask?: number | null } export interface RfqMarket extends RfqLevels { /** "Composite", "Mid", the name of the reference. Default "Market". */ label?: string /** A mid when the reference has no sides; a blank field steps from it when nothing else is there. */ mid?: number | null } export interface RfqContextItem { label: string /** Printed as given: a position, a risk number, a book. Format it before it gets here. */ value: string tone?: "up" | "down" | "flat" } export interface RfqInquiry { id: string instrument: RfqInstrument /** The client's side. `two-way` asks for a market: a bid and an offer. */ side: RfqSide /** Notional, or contracts when the convention says so. */ quantity: number client?: RfqClient /** The venue's words: the protocol, dealers in competition, a list. Printed as tags. */ tags?: readonly string[] settlement?: string /** When it arrived, for the countdown's full width. */ receivedAt?: number /** When the venue ends it. */ expiresAt: number context?: readonly RfqContextItem[] /** The market beside the quote. */ market?: RfqMarket /** The levels the server holds as this desk's live quote, if any. */ quoted?: RfqLevels /** Levels the server suggests, an auto-quoter's for one. Offered as one click and one key. */ suggested?: RfqLevels /** The venue's word for where the inquiry stands. Printed as it is. */ status: string /** The server's second line: a reason, a cover, a note. Printed as it is. */ message?: string /** What the server says may be done now. No list means nothing, and no buttons. */ allowedActions?: readonly string[] } export interface RfqQuoteDraft { inquiryId: string bid: number | null ask: number | null /** The size the quote is for: the inquiry's, unless a quick size was taken. */ quantity?: number } export interface RfqAction { /** Matched against `allowedActions`. */ id: string /** A verb: "Quote", "Pass". A function of the draft may say more. */ label: string | ((draft: RfqQuoteDraft) => string) /** The draft, checked when the action needs a quote. */ run: (draft: RfqQuoteDraft, inquiry: RfqInquiry) => void /** The action sends the levels in the fields, so the check wants them. Default true; a pass, a stop, or a quote the server prices itself wants none. */ needsQuote?: boolean destructive?: boolean /** What `rfq.send` runs. The first allowed action that needs a quote by default, else the first allowed. */ primary?: boolean } export interface RfqTicketLabels { /** The quick-size row's word: the size the quote is for. */ quoteFor: string ticket: string client: string buys: string sells: string asksMarket: string bid: string ask: string market: string quoted: string suggested: string takeSuggested: string vsMarket: string settlement: string bidNeeded: string askNeeded: string crossed: string nothingAllowed: string for: string /** The primary action's words while a limit asks again; `{action}` is its label. */ anyway: string } export const DEFAULT_RFQ_TICKET_LABELS: RfqTicketLabels = { quoteFor: "For", ticket: "Inquiry", client: "A client", buys: "buys", sells: "sells", asksMarket: "asks a market in", bid: "Bid", ask: "Offer", market: "Market", quoted: "Quoted", suggested: "Auto", takeSuggested: "Take the suggested levels", vsMarket: "vs market", settlement: "Settles", bidNeeded: "A bid is needed.", askNeeded: "An offer is needed.", crossed: "The bid is above the offer.", nothingAllowed: "Nothing can be done with this inquiry right now.", for: "for", anyway: "{action} anyway?", } /** `mod+1` to `mod+9`: the quick sizes, in order. */ export const QUICK_SIZE_KEYS: readonly string[] = ["mod+1", "mod+2", "mod+3", "mod+4", "mod+5", "mod+6", "mod+7", "mod+8", "mod+9"] /** The keys a ticket answers to, all `editing`: they run while you type in it. Declared by the ticket when you have not. */ export const RFQ_TICKET_BINDINGS: readonly HotkeyBinding[] = [ { id: "rfq.send", keys: "mod+enter", scope: "editing", description: "Send the quote", group: "Inquiry" }, { id: "rfq.tick-up", keys: "mod+up", scope: "editing", description: "Level up one tick", group: "Inquiry" }, { id: "rfq.tick-down", keys: "mod+down", scope: "editing", description: "Level down one tick", group: "Inquiry" }, { id: "rfq.suggested", keys: "mod+shift+a", scope: "editing", description: "Take the suggested levels", group: "Inquiry" }, ...QUICK_SIZE_KEYS.map((keys, i) => ({ id: `rfq.size-${i + 1}`, keys, scope: "editing" as const, description: `Quote for quick size ${i + 1}`, group: "Inquiry" })), ] /** The sides the dealer quotes: a client who buys gets an offer, one who sells gets a bid, a market gets both. */ export function quotedSides(side: RfqSide): readonly QuoteSide[] { return side === "buy" ? ["ask"] : side === "sell" ? ["bid"] : ["bid", "ask"] } /** The inquiry's size the way the desk says it: millions of notional, or a count of contracts. */ export function formatSize(quantity: number, convention: InstrumentConvention): string { return convention.quantityUnit === "contracts" ? formatQuantity(quantity) : formatNotional(quantity, { unit: "mm" }) } export interface RfqQuoteProblems { bid?: string ask?: string } /** What stops a quote from being sent: a needed side that is blank, or a market whose bid is above its offer. Empty when nothing does. */ export function checkQuote(draft: RfqQuoteDraft, inquiry: RfqInquiry, labels: RfqTicketLabels = DEFAULT_RFQ_TICKET_LABELS): RfqQuoteProblems { const problems: RfqQuoteProblems = {} const sides = quotedSides(inquiry.side) if (sides.includes("bid") && draft.bid === null) problems.bid = labels.bidNeeded if (sides.includes("ask") && draft.ask === null) problems.ask = labels.askNeeded if (draft.bid !== null && draft.ask !== null && draft.bid > draft.ask) problems.ask = labels.crossed return problems } /** "Offer 5mm T 4 1/8 05/15/34 @ 99-16+", "Bid 5mm … @ 99-15+", or "99-15+ / 99-16 for 5mm …" for a market. */ export function describeQuote(draft: RfqQuoteDraft, inquiry: RfqInquiry, labels: RfqTicketLabels = DEFAULT_RFQ_TICKET_LABELS): string { const { convention } = inquiry.instrument const what = `${formatSize(draft.quantity ?? inquiry.quantity, convention)} ${inquiry.instrument.description ?? inquiry.instrument.symbol}` const level = (v: number | null) => (v === null ? "" : ` @ ${formatQuote(v, convention)}`) if (inquiry.side === "buy") return `${labels.ask} ${what}${level(draft.ask)}` if (inquiry.side === "sell") return `${labels.bid} ${what}${level(draft.bid)}` return `${formatQuote(draft.bid, convention)} / ${formatQuote(draft.ask, convention)} ${labels.for} ${what}` } /** How far a level sits from the market's same side, in the instrument's own steps: ticks for a price, basis points for a yield, discount, or spread. Null when a side is missing. */ export function quoteDistance(level: number | null | undefined, market: number | null | undefined, convention: InstrumentConvention): { value: number; text: string } | null { if (typeof level !== "number" || typeof market !== "number" || !Number.isFinite(level) || !Number.isFinite(market)) return null const basis = quoteBasisOf(convention) if (basis === "price") { const value = ticksBetween(level, market, convention.tick) return { value, text: formatTicks(value) } } const value = Number(((level - market) * (basis === "spread" ? 1 : 100)).toFixed(2)) return { value, text: formatBps(value, { signed: true }) } } // Several tickets can be up at once, so the bindings are declared once per registry and taken // back when the last ticket that leaned on them leaves. A consumer that declared an id owns it. const declared = new WeakMap<HotkeyRegistry, Map<string, { count: number; ours: boolean }>>() function declareBindings(registry: HotkeyRegistry, bindings: readonly HotkeyBinding[]): () => void { let table = declared.get(registry) if (!table) declared.set(registry, (table = new Map())) const have = new Set(registry.list().map((entry) => entry.id)) for (const binding of bindings) { const entry = table.get(binding.id) if (entry) entry.count += 1 else { const ours = !have.has(binding.id) if (ours) registry.register(binding) table.set(binding.id, { count: 1, ours }) } } return () => { for (const binding of bindings) { const entry = table.get(binding.id) if (!entry) continue entry.count -= 1 if (entry.count > 0) continue table.delete(binding.id) if (entry.ours) registry.unregister(binding.id) } } } const noop = () => () => {} const level = (v: number | null | undefined) => (typeof v === "number" ? v : null) export interface RfqTicketProps { inquiry: RfqInquiry actions: readonly RfqAction[] /** Where the fields start. Change the `key` to start again. */ defaultDraft?: Partial<RfqLevels> onDraftChange?: (draft: RfqQuoteDraft) => void /** Anything that changes identity when the server acknowledges a send: a quote id, a timestamp. The ticket rings once in `primary`. */ acknowledged?: unknown /** Put the keyboard in the first quote field on mount. Off by default: the parent decides where focus goes when an inquiry becomes active. */ autoFocus?: boolean disabled?: boolean /** Declare `RFQ_TICKET_BINDINGS` in the hotkey registry when they are not. Default true. */ hotkeys?: boolean /** The desk's lines, from `limits`: a block shows under its field and holds the actions that send a quote; a confirm makes the action ask again. Each level is checked against the inquiry's market. */ limits?: Limits /** Sizes the quote may be for besides the inquiry's, as buttons and `mod+1` to `mod+9`, printed in the convention's unit. */ quickSizes?: readonly number[] labels?: Partial<RfqTicketLabels> className?: string } const TONE_CLASS = { up: "text-up", down: "text-down", flat: "text-muted-foreground" } as const export function RfqTicket({ inquiry, actions, defaultDraft, onDraftChange, acknowledged, autoFocus = false, disabled = false, hotkeys: declareHotkeys = true, limits, quickSizes, labels: labelsProp, className }: RfqTicketProps) { const labels = { ...DEFAULT_RFQ_TICKET_LABELS, ...labelsProp } const id = useId() const { convention } = inquiry.instrument const sides = quotedSides(inquiry.side) const [draft, setDraft] = useState<RfqQuoteDraft>(() => ({ inquiryId: inquiry.id, bid: level(defaultDraft?.bid), ask: level(defaultDraft?.ask), quantity: inquiry.quantity })) const [problems, setProblems] = useState<RfqQuoteProblems>({}) // The action a limit asked again about; the next click on it sends. Any change to a level withdraws the question. const [confirming, setConfirming] = useState<string | null>(null) const box = useRef<HTMLDivElement>(null) const inputs = { bid: useRef<HTMLInputElement>(null), ask: useRef<HTMLInputElement>(null) } const latest = useRef({ onDraftChange, actions, inquiry, draft, labels, disabled, limits, confirming, quickSizes }) useEffect(() => { latest.current = { onDraftChange, actions, inquiry, draft, labels, disabled, limits, confirming, quickSizes } }) // The draft is told after it changed, never on the first render. const told = useRef(draft) useEffect(() => { if (told.current === draft) return told.current = draft latest.current.onDraftChange?.(draft) }, [draft]) useEffect(() => { if (autoFocus) inputs[sides[0]!].current?.focus() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // The acknowledgement: a ring in primary, once, when the server says so. No direction, because it has none. useFlash(box, acknowledged, { variant: "ring", color: "var(--primary)" }) const allowed = actions.filter((action) => inquiry.allowedActions?.includes(action.id)) const primary = allowed.find((action) => action.primary) ?? allowed.find((action) => action.needsQuote !== false) ?? allowed[0] // The fields are live while some allowed action would send what is in them. const quoting = !disabled && allowed.some((action) => action.needsQuote !== false) /** The size the quote is for: the inquiry's own, or the n-th quick size (from 1). */ function setQuantity(quantity: number) { setDraft((d) => (d.quantity === quantity ? d : { ...d, quantity })) setConfirming(null) } function quick(n: number) { const size = latest.current.quickSizes?.[n - 1] if (size !== undefined && !latest.current.disabled) setQuantity(size) } function setLevel(side: QuoteSide, value: number | null) { setDraft((d) => (d[side] === value ? d : { ...d, [side]: value })) setProblems((p) => (p[side] ? { ...p, [side]: undefined } : p)) setConfirming(null) } // The limits, live: a block shows under its field and holds the actions that send a quote; a confirm waits for the click. const quotedDraft = { bid: sides.includes("bid") ? draft.bid : null, ask: sides.includes("ask") ? draft.ask : null } const limitProblems = limits ? checkLimits(quotedDraft, limits, { market: inquiry.market, convention }) : [] const blockedBy = problemsByField(blocks(limitProblems)) const blocked = Object.keys(blockedBy).length > 0 const shownProblems = { bid: problems.bid ?? blockedBy.bid, ask: problems.ask ?? blockedBy.ask } const otherBlocks = blocks(limitProblems).filter((p) => p.field !== "bid" && p.field !== "ask") const asking = confirming !== null ? confirms(limitProblems) : [] /** Where a step starts when a field is blank: the market's same side, the suggested level, the market's mid, then its other side. */ function stepFrom(side: QuoteSide): number | null { const market = inquiry.market ?? {} const suggested = inquiry.suggested ?? {} const own = level(market[side]) if (own !== null) return own const hint = level(suggested[side]) if (hint !== null) return hint const bid = level(market.bid) const ask = level(market.ask) if (bid !== null && ask !== null) return stepQuote((bid + ask) / 2, convention, 0) return bid ?? ask ?? level(market.mid) } function step(side: QuoteSide, steps: number) { const from = latest.current.draft[side] ?? stepFrom(side) if (from === null) return setLevel(side, stepQuote(from, convention, steps)) } /** The field the keyboard is in, else the first side the client asked for. */ function focusedSide(): QuoteSide { const active = typeof document === "undefined" ? null : document.activeElement for (const side of sides) if (inputs[side].current && inputs[side].current === active) return side return sides[0]! } function takeSuggested() { const suggested = latest.current.inquiry.suggested if (!suggested) return for (const side of sides) { const value = level(suggested[side]) if (value !== null) setLevel(side, value) } } function run(action: RfqAction) { // Checked as the click lands, against the props as they are now: an action can stop being allowed. const { draft: current, inquiry: now, labels: words, disabled: off, limits: lines, confirming: asked } = latest.current if (off || !now.allowedActions?.includes(action.id)) return if (action.needsQuote !== false) { const found = checkQuote(current, now, words) // The limits, as the click lands: a block stops here and shows under its field; a confirm asks once, and the next click on the same action sends. const quoted = quotedSides(now.side) const over = lines ? checkLimits({ bid: quoted.includes("bid") ? current.bid : null, ask: quoted.includes("ask") ? current.ask : null }, lines, { market: now.market, convention: now.instrument.convention }) : [] const stopped = problemsByField(blocks(over)) setProblems({ bid: found.bid ?? stopped.bid, ask: found.ask ?? stopped.ask }) if (found.bid || found.ask || Object.keys(stopped).length) return if (confirms(over).length && asked !== action.id) { setConfirming(action.id) return } } setConfirming(null) action.run(current, now) } // Keys: declared once per registry, bound to this ticket's box so another ticket's keys stay its own. const registry = useMaybeHotkeys() const handlers = useRef({ send: () => {}, up: () => {}, down: () => {}, suggested: () => {}, quick: (n: number) => { void n } }) useEffect(() => { handlers.current = { send: () => { const { actions: list, inquiry: now } = latest.current const open = list.filter((action) => now.allowedActions?.includes(action.id)) const first = open.find((action) => action.primary) ?? open.find((action) => action.needsQuote !== false) ?? open[0] if (first) run(first) }, up: () => step(focusedSide(), 1), down: () => step(focusedSide(), -1), suggested: takeSuggested, quick, } }) useEffect(() => { if (!registry) return const release = declareHotkeys ? declareBindings(registry, RFQ_TICKET_BINDINGS) : noop() const within = { scope: "editing", element: () => box.current } const guard = (fn: () => void) => (event: KeyboardEvent) => { event.preventDefault() fn() } const unbind = [ registry.bind("rfq.send", guard(() => handlers.current.send()), within), registry.bind("rfq.tick-up", guard(() => handlers.current.up()), within), registry.bind("rfq.tick-down", guard(() => handlers.current.down()), within), registry.bind("rfq.suggested", guard(() => handlers.current.suggested()), within), ...QUICK_SIZE_KEYS.map((_, i) => registry.bind(`rfq.size-${i + 1}`, guard(() => handlers.current.quick(i + 1)), within)), ] return () => { for (const u of unbind) u() release() } }, [registry, declareHotkeys]) const sendKeys = useSyncExternalStore( registry?.subscribe ?? noop, () => registry?.list().find((entry) => entry.id === "rfq.send")?.keys ?? null, () => null, ) const sideWord = inquiry.side === "buy" ? labels.buys : inquiry.side === "sell" ? labels.sells : labels.asksMarket const size = formatSize(inquiry.quantity, convention) const description = inquiry.instrument.description ?? inquiry.instrument.symbol const market = inquiry.market const marketLabel = market?.label ?? labels.market const quotedLevels = inquiry.quoted const showMarket = Boolean(market || quotedLevels) const suggestedText = inquiry.suggested ? sides.map((side) => formatQuote(level(inquiry.suggested![side]), convention)).join(" / ") : null const hasSuggested = Boolean(inquiry.suggested && sides.some((side) => level(inquiry.suggested![side]) !== null)) return ( <HotkeyScope scope="editing" role="group" aria-label={`${labels.ticket} ${inquiry.id}`} data-slot="tradecn-rfq-ticket" data-inquiry={inquiry.id} data-side={inquiry.side} data-status={inquiry.status} className={cn("block outline-none lining-nums tabular-nums", className)}> <div ref={box} className="flex flex-col gap-2 rounded-md border border-border bg-card p-2 text-xs text-card-foreground"> <div className="flex items-start gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex flex-wrap items-baseline gap-x-1.5" data-rfq-headline> <span className="font-semibold">{inquiry.client?.name ?? labels.client}</span>{" "} <span className="text-muted-foreground">{sideWord}</span>{" "} <span className="font-(family-name:--tradecn-font-mono) font-semibold lining-nums tabular-nums" data-numeric=""> {size} </span>{" "} <span className="font-(family-name:--tradecn-font-mono) font-semibold" data-rfq-instrument> {description} </span> </div> <div className="flex flex-wrap items-center gap-1 text-muted-foreground"> {inquiry.client?.tier && ( <Badge variant="outline" className="h-4 px-1 text-xs" data-rfq-tier> {inquiry.client.tier} </Badge> )} {inquiry.tags?.map((tag) => ( <Badge key={tag} variant="secondary" className="h-4 px-1 text-xs" data-rfq-tag> {tag} </Badge> ))} {inquiry.client?.trader && <span>{inquiry.client.trader}</span>} {inquiry.client?.salesperson && <span>· {inquiry.client.salesperson}</span>} {inquiry.settlement && ( <span> · {labels.settlement} {inquiry.settlement} </span> )} </div> </div> <div className="flex shrink-0 flex-col items-end gap-1"> <Badge variant="secondary" className="h-5 px-1.5 font-medium" data-rfq-status> {inquiry.status} </Badge> <Countdown expiresAt={inquiry.expiresAt} startsAt={inquiry.receivedAt} label={`${labels.ticket} ${inquiry.id}`} className="text-sm" /> </div> </div> {inquiry.context && inquiry.context.length > 0 && ( <dl className="flex flex-wrap gap-x-3 gap-y-0.5 lining-nums tabular-nums" data-rfq-context> {inquiry.context.map((item) => ( <div key={item.label} className="flex gap-1"> <dt className="text-muted-foreground">{item.label}</dt> <dd className={cn("font-medium", item.tone && TONE_CLASS[item.tone])}>{item.value}</dd> </div> ))} </dl> )} {showMarket && ( <div className={cn("grid gap-x-3 gap-y-0.5", numericFontClass(convention))} style={{ gridTemplateColumns: `auto repeat(${sides.length}, minmax(0, 1fr))` }} data-rfq-market data-numeric=""> <span /> {sides.map((side) => ( <span key={side} className="text-right text-muted-foreground"> {labels[side]} </span> ))} {market && ( <> <span className="text-muted-foreground">{marketLabel}</span> {sides.map((side) => ( <span key={side} className="text-right" data-rfq-market-level={side}> {formatQuote(level(market[side]), convention)} </span> ))} </> )} {quotedLevels && ( <> <span className="text-muted-foreground">{labels.quoted}</span> {sides.map((side) => ( <span key={side} className="text-right font-semibold" data-rfq-quoted={side}> {formatQuote(level(quotedLevels[side]), convention)} </span> ))} </> )} </div> )} <div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${sides.length}, minmax(0, 1fr))` }}> {sides.map((side) => { const distance = quoteDistance(draft[side], level(market?.[side]), convention) return ( <div key={side} className="flex flex-col gap-0.5"> <QuoteField id={`${id}-${side}`} convention={convention} label={labels[side]} side={side} value={draft[side]} onValueChange={(value) => setLevel(side, value)} stepFrom={stepFrom(side)} disabled={!quoting} error={shownProblems[side]} inputRef={inputs[side]} /> <span className="h-4 text-right text-muted-foreground lining-nums tabular-nums" data-rfq-distance={side} data-numeric="" aria-live="off"> {distance ? `${distance.text} ${labels.vsMarket}` : " "} </span> </div> ) })} </div> {quickSizes && quickSizes.length > 0 && ( <div role="group" aria-label={labels.quoteFor} data-rfq-quick-sizes="" className="flex flex-wrap items-center gap-1"> <span className="text-muted-foreground">{labels.quoteFor}</span> {[inquiry.quantity, ...quickSizes.filter((size) => size !== inquiry.quantity)].map((size) => ( <Button key={size} type="button" variant="outline" size="sm" className={cn("h-6 px-1.5 text-xs aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent dark:aria-pressed:text-accent-foreground", NUMERIC_CLASS)} disabled={!quoting} aria-pressed={(draft.quantity ?? inquiry.quantity) === size} aria-label={`${labels.quoteFor} ${formatSize(size, convention)}`} data-quick-size={size} onClick={() => setQuantity(size)}> {formatSize(size, convention)} </Button> ))} </div> )} <div className="flex flex-wrap items-center gap-2" data-rfq-actions={allowed.length}> {hasSuggested && ( <Button type="button" variant="ghost" size="sm" className={cn("h-7 gap-1 px-1.5", numericFontClass(convention))} disabled={!quoting} aria-label={`${labels.takeSuggested}: ${suggestedText}`} data-rfq-suggested onClick={takeSuggested}> <span className="text-muted-foreground">{labels.suggested}</span> {suggestedText} </Button> )} <span className="ml-auto flex items-center gap-2"> {allowed.length === 0 ? ( <p className="text-muted-foreground">{labels.nothingAllowed}</p> ) : ( allowed.map((action) => ( <Button key={action.id} type="button" variant={action.destructive ? "destructive" : action === primary ? "default" : "outline"} size="sm" className="h-7 gap-2" disabled={disabled || (blocked && action.needsQuote !== false)} data-action={action.id} data-confirming={confirming === action.id || undefined} onClick={() => run(action)} > {confirming === action.id ? labels.anyway.replace("{action}", typeof action.label === "function" ? action.label(draft) : action.label) : typeof action.label === "function" ? action.label(draft) : action.label} {action === primary && sendKeys && ( <KbdGroup aria-hidden> {formatKeys(sendKeys)[0]?.map((cap) => ( <Kbd key={cap}>{cap}</Kbd> ))} </KbdGroup> )} </Button> )) )} </span> </div> {(otherBlocks.length > 0 || asking.length > 0) && ( <p className={asking.length ? "text-stale" : "text-destructive"} data-rfq-limits={asking.length ? "confirm" : "block"}> {[...otherBlocks, ...asking].map((p) => p.message).join(" ")} </p> )} {inquiry.message && ( <p className="border-t border-border pt-1.5 text-muted-foreground" data-rfq-message> {inquiry.message} </p> )} </div> </HotkeyScope> ) }components/ui/ quote-field.tsx import { cn } from "cn" import { useId, useState, type KeyboardEvent, type RefObject } from "react" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group" import { QUOTE_BASIS_LABELS, formatQuote, numericFontClass, parseQuote, quoteBasisOf, stepQuote, type InstrumentConvention } from "@/lib/format" // A field that types a quote the way the instrument quotes it: 99-16+ for a note on price, 4.253 // for a bill on discount, 12.6 for credit on a spread. It parses on every keystroke and hands the // number up, marks what is not a quote on blur and prints a good one back in the notation, and steps // by the instrument's own step with the arrows and the two buttons, ten steps with Shift held. With a // modifier held the arrows are not its business: they belong to whoever listens above it, which is // how a ticket's mod+up reaches its registry from inside this field. // // It is controlled, so the value is the parent's. When the parent moves it (a step from a key, a click // on a reference price, a reset) the text follows; when the text already reads as the new value, as it // does while someone is typing, the text is left alone under the cursor. export interface QuoteFieldProps { /** How the quote prints, parses, and steps, and in what basis. */ convention: InstrumentConvention /** The quote, or null while the field is blank or not a quote. */ value: number | null onValueChange: (value: number | null) => void /** Where a step starts when the field is blank. Left out, the arrows do nothing on a blank field. */ stepFrom?: number | null /** The label, and the word in the button names. Default: the basis word, Price, Yield, Discount, or Spread. */ label?: string /** A problem your check found with the value, printed under the field. */ error?: string /** Said under the field when the text is not a quote. Default names the basis. */ invalidText?: string /** Default: zero in the notation. */ placeholder?: string id?: string disabled?: boolean /** What Shift multiplies a step by. Default 10. */ shiftMultiplier?: number /** The side of a two-sided quote this field is, as `data-side` on the root. */ side?: "bid" | "ask" inputRef?: RefObject<HTMLInputElement | null> className?: string } export function QuoteField({ convention, value, onValueChange, stepFrom = null, label, error, invalidText, placeholder, id: idProp, disabled = false, shiftMultiplier = 10, side, inputRef, className }: QuoteFieldProps) { const basis = quoteBasisOf(convention) const word = label ?? QUOTE_BASIS_LABELS[basis] const generated = useId() const id = idProp ?? generated const [text, setText] = useState(() => (value === null ? "" : formatQuote(value, convention))) const [invalid, setInvalid] = useState(false) // Derived state, set during render: the parent moved the value, so the text follows, unless the // text already reads as that value (someone is typing it) and would only be reformatted under them. const [known, setKnown] = useState(value) if (value !== known) { setKnown(value) if (parseQuote(text, convention) !== value) { setText(value === null ? "" : formatQuote(value, convention)) setInvalid(false) } } function change(next: string) { setText(next) setInvalid(false) onValueChange(parseQuote(next, convention)) } function blur() { if (!text.trim()) return setInvalid(false) const parsed = parseQuote(text, convention) if (parsed === null) return setInvalid(true) setText(formatQuote(parsed, convention)) } function step(steps: number) { const from = value ?? stepFrom if (from === null || from === undefined) return const next = stepQuote(from, convention, steps) setText(formatQuote(next, convention)) setInvalid(false) onValueChange(next) } // Bare arrows step. With a modifier held the key belongs to the registry above. function keyDown(event: KeyboardEvent<HTMLInputElement>) { if ((event.key !== "ArrowUp" && event.key !== "ArrowDown") || event.ctrlKey || event.metaKey || event.altKey) return event.preventDefault() step((event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? shiftMultiplier : 1)) } const problem = error ?? (invalid ? (invalidText ?? `Not a ${word.toLowerCase()} in this instrument's notation.`) : undefined) return ( <div data-slot="tradecn-quote-field" data-basis={basis} data-side={side} className={cn("block lining-nums tabular-nums", className)}> <Field data-invalid={problem ? true : undefined}> <FieldLabel htmlFor={id}>{word}</FieldLabel> <InputGroup className="h-7"> <InputGroupInput ref={inputRef} id={id} value={text} placeholder={placeholder ?? formatQuote(0, convention)} inputMode="decimal" autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={problem ? true : undefined} data-numeric="" className={cn(numericFontClass(convention), "text-xs md:text-xs")} onChange={(event) => change(event.target.value)} onBlur={blur} onKeyDown={keyDown} /> <InputGroupAddon align="inline-end" className="gap-0"> <InputGroupButton type="button" size="icon-xs" aria-label={`${word} down one tick`} disabled={disabled} data-step="-1" onClick={() => step(-1)}> − </InputGroupButton> <InputGroupButton type="button" size="icon-xs" aria-label={`${word} up one tick`} disabled={disabled} data-step="1" onClick={() => step(1)}> + </InputGroupButton> </InputGroupAddon> </InputGroup> {problem && <FieldError>{problem}</FieldError>} </Field> </div> ) }components/ui/ countdown.tsx import { cn } from "cn" import { useEffect, useLayoutEffect, useRef, useState } 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. */ export function formatRemaining(ms: number): string { 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 } 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 now = useNow(c) const remaining = expiresAt - now const tier = countdownTier(remaining, thresholds) // The bar's full width is `startsAt` to `expiresAt`; without `startsAt`, from first sight to the end. const [firstSeen] = useState(now) const total = Math.max(1, expiresAt - (startsAt ?? firstSeen)) const fraction = Math.max(0, Math.min(1, remaining / total)) const [reduced] = useState(prefersReducedMotion) // Drawn from the digits' tick when it cannot animate: reduced motion, or nothing left to animate. const staticBar = reduced || tier === "expired" const bar = useRef<HTMLSpanElement>(null) useLayoutEffect(() => { const el = bar.current if (!el || staticBar || typeof el.animate !== "function") return const left = expiresAt - c.now() if (left <= 0) return 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. 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)}`) } return ( <span role="timer" aria-label={label} data-slot="tradecn-countdown" data-tier={tier} 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 === "soon" && !compact && "bg-expiring-soft", TIER_CLASS[tier], className)} > <span 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 { 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() return useSyncExternalStore(c.subscribe, c.now, c.now) }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) useEffect(() => { latest.current = handler }) useEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }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 } /** One interval shared by every subscriber; it runs only while someone is listening. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, intervalMs) } 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 } 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>() 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) 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) } } /** * 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") 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 return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 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. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).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, whose dash, ticks, and tail must line up down a column. */ 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 * 99-16+ over 99-17 keeps its dash and its tail in the same place; 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" } /** 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : 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), } }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall: only scopes declared inside it are active, so a // single-key binding cannot fire under a modal confirmation. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is one, else the binding's own. */ keys: string defaultKeys: string remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } for (const token of part.toLowerCase().split("+")) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() const chordTimeoutMs = options.chordTimeoutMs ?? 1000 const recs = new Map<string, Rec>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const defaults = parseKeys(binding.keys, platform) let steps = defaults const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = defaults } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change. if (previous && !handler && sameBinding(previous.binding, binding)) return conflictsFor(binding.id) recs.set(binding.id, build(binding, handler ?? previous?.handler)) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { if (!recs.delete(id)) return clearPending() emit() }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) return { ...rec.binding, keys, defaultKeys, remapped: keys !== defaultKeys } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } }lib/limits.ts import { formatQuantity, formatTicks, quoteBasisOf, ticksBetween, type InstrumentConvention } from "@/lib/format" // Fat-finger checks are universal: a size above a line, a level too far from the market, a side the // book may not take. The tickets already stop a missing level and a crossed market; this gives them a // limits table, as data, and returns problems in the shape they already print. A `block` stops the // action; a `confirm` turns it into a two-step, the blotter's ask-again. Nothing here decides a // limit: the numbers are the desk's, the words are the consumer's, and the check runs where the // click lands, against the draft as it is then. export type ProblemLevel = "block" | "confirm" export interface Problem { /** The field it is about: "quantity", "price", "bid", "ask", or "side". */ field: string level: ProblemLevel /** A sentence, in the consumer's words. */ message: string /** Which limit raised it: "maxQuantity", "minQuantity", "maxDistance", "sides", or a custom rule's own name. */ rule: string } /** What the check reads of a draft: a ticket's side, quantity, and price, or a quote's bid and ask. */ export interface LimitsDraft { side?: "buy" | "sell" quantity?: number | null price?: number | null bid?: number | null ask?: number | null } export interface LimitsMarket { bid?: number | null ask?: number | null last?: number | null mid?: number | null } export interface LimitsLabels { /** `{n}` the quantity, `{max}` the limit. */ quantityAbove: string quantityAboveConfirm: string /** `{n}` the quantity, `{min}` the limit. */ quantityBelow: string quantityBelowConfirm: string /** `{field}` the level's field, `{distance}` how far, `{max}` the limit, both with their unit. */ tooFar: string tooFarConfirm: string /** `{side}` the side. */ sideNotAllowed: string buy: string sell: string ticks: string bps: string } export const DEFAULT_LIMITS_LABELS: LimitsLabels = { quantityAbove: "{n} is above the size limit of {max}.", quantityAboveConfirm: "{n} is above {max}. Send it anyway?", quantityBelow: "{n} is below the minimum of {min}.", quantityBelowConfirm: "{n} is below {min}. Send it anyway?", tooFar: "The {field} is {distance} from the market; the limit is {max}.", tooFarConfirm: "The {field} is {distance} from the market, past {max}. Send it anyway?", sideNotAllowed: "The book does not take a {side}.", buy: "buy", sell: "sell", ticks: "ticks", bps: "bp", } export interface LimitsContext { market?: LimitsMarket convention?: InstrumentConvention labels?: Partial<LimitsLabels> } /** A line, or two: a size to ask again past, and a size to stop at. One number is a stop. */ export type Threshold = number | { confirm?: number; block?: number } export interface Limits { maxQuantity?: Threshold minQuantity?: Threshold /** How far a level may sit from the market's same side (the mid when there is none), in ticks for a price basis or basis points for the rest. */ maxDistance?: ({ ticks: number } | { bps: number }) & { level?: ProblemLevel } /** The sides the book may take. A ticket's side, or, for a quote, a bid is a buy and an offer a sell. */ sides?: readonly ("buy" | "sell")[] /** Your own rules, in the same shape. */ custom?: (draft: LimitsDraft, context: LimitsContext) => Problem[] } function fill(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? "")) } function thresholds(t: Threshold | undefined): { confirm?: number; block?: number } { if (t === undefined) return {} return typeof t === "number" ? { block: t } : t } /** The market's side a level is measured against: the same side as the level, else the mid, else the last. */ export function marketSideFor(field: "price" | "bid" | "ask", side: "buy" | "sell" | undefined, market: LimitsMarket | undefined): number | null { if (!market) return null const pick = (...values: (number | null | undefined)[]) => values.find((v): v is number => typeof v === "number" && Number.isFinite(v)) ?? null const mid = pick(market.mid) ?? (typeof market.bid === "number" && typeof market.ask === "number" ? (market.bid + market.ask) / 2 : null) if (field === "bid") return pick(market.bid, mid, market.last) if (field === "ask") return pick(market.ask, mid, market.last) // A price on a ticket: a buyer measures against the offer, a seller against the bid. if (side === "buy") return pick(market.ask, mid, market.last) if (side === "sell") return pick(market.bid, mid, market.last) return pick(mid, market.last) } /** How far a level is from a market level in the convention's unit: ticks for a price basis, basis points for the rest. */ export function distanceFromMarket(level: number, market: number, convention: InstrumentConvention | undefined): { value: number; unit: "ticks" | "bps" } { if (!convention || quoteBasisOf(convention) === "price") return { value: Math.abs(ticksBetween(level, market, convention?.tick ?? 1)), unit: "ticks" } // A yield or a discount is quoted in percent, so a hundredth of a point is a basis point; a spread is quoted in basis points already. const perPoint = quoteBasisOf(convention) === "spread" ? 1 : 100 // To a thousandth of a basis point, so 4.30 against 4.25 is 5 and not 4.999999999999982. return { value: Math.round(Math.abs(level - market) * perPoint * 1000) / 1000, unit: "bps" } } /** * What the limits say about a draft, in the tickets' shape, each with its level and a sentence. Empty * when nothing does. Quantity against the lines, each level against the market in the instrument's * unit, the side against the sides the book takes, then your own rules. */ export function checkLimits(draft: LimitsDraft, limits: Limits | undefined, context: LimitsContext = {}): Problem[] { if (!limits) return [] const labels = { ...DEFAULT_LIMITS_LABELS, ...context.labels } const problems: Problem[] = [] const quantity = draft.quantity if (typeof quantity === "number" && Number.isFinite(quantity)) { const max = thresholds(limits.maxQuantity) if (max.block !== undefined && quantity > max.block) problems.push({ field: "quantity", level: "block", rule: "maxQuantity", message: fill(labels.quantityAbove, { n: formatQuantity(quantity), max: formatQuantity(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: formatQuantity(quantity), max: formatQuantity(max.confirm) }) }) const min = thresholds(limits.minQuantity) if (min.block !== undefined && quantity < min.block) problems.push({ field: "quantity", level: "block", rule: "minQuantity", message: fill(labels.quantityBelow, { n: formatQuantity(quantity), min: formatQuantity(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: formatQuantity(quantity), min: formatQuantity(min.confirm) }) }) } if (limits.maxDistance) { const rule = limits.maxDistance const level = rule.level ?? "block" const max = "ticks" in rule ? { value: rule.ticks, unit: "ticks" as const } : { value: rule.bps, unit: "bps" as const } for (const field of ["price", "bid", "ask"] as const) { const value = draft[field] if (typeof value !== "number" || !Number.isFinite(value)) continue const against = marketSideFor(field, draft.side, context.market) if (against === null) continue const distance = distanceFromMarket(value, against, context.convention) if (distance.unit !== max.unit || !(distance.value > max.value)) continue const unit = labels[max.unit] const words = { field, distance: `${formatTicks(distance.value, { signed: false })} ${unit}`, max: `${formatTicks(max.value, { signed: false })} ${unit}` } problems.push({ field, level, rule: "maxDistance", message: fill(level === "block" ? labels.tooFar : labels.tooFarConfirm, words) }) } } if (limits.sides) { const taken = new Set(limits.sides) const refuse = (side: "buy" | "sell", field: string) => problems.push({ field, level: "block", rule: "sides", message: fill(labels.sideNotAllowed, { side: labels[side] }) }) if (draft.side && !taken.has(draft.side)) refuse(draft.side, "side") if (typeof draft.bid === "number" && !taken.has("buy")) refuse("buy", "bid") if (typeof draft.ask === "number" && !taken.has("sell")) refuse("sell", "ask") } if (limits.custom) problems.push(...limits.custom(draft, context)) return problems } /** The problems that stop the action. */ export function blocks(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "block") } /** The problems that ask again. */ export function confirms(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "confirm") } /** The first message per field, for printing under the fields the way the tickets do. */ export function problemsByField(problems: readonly Problem[]): Record<string, string> { const out: Record<string, string> = {} for (const p of problems) if (!(p.field in out)) out[p.field] = p.message return out } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --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; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --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; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
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 { useState } from "react"
import { RfqTicket, describeQuote, type RfqAction, type RfqInquiry } from "@/components/rfq-ticket"
function InquiryTicket() {
const [inquiry] = useState<RfqInquiry>(() => {
const now = Date.now()
return {
id: "Q-1",
instrument: { symbol: "T10", description: "10Y Treasury", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 } },
client: { name: "ALPHA" },
side: "buy",
quantity: 5_000_000,
receivedAt: now,
expiresAt: now + 60_000,
market: { bid: 99.5, ask: 99.515625 },
status: "Open",
allowedActions: ["quote"],
}
})
const [request, setRequest] = useState("None")
const actions: RfqAction[] = [
{ id: "quote", label: "Quote", run: (draft, inquiry) => setRequest(describeQuote(draft, inquiry)) },
]
return (
<div className="w-[26rem] max-w-full space-y-2 text-xs lining-nums tabular-nums">
<RfqTicket key={inquiry.id} inquiry={inquiry} actions={actions} />
<p role="status" className="text-muted-foreground">Last request: {request}</p>
</div>
)
}
A buyer needs your offer. Enter 99-16+ or step from the market with the arrow keys, then choose Quote. The ticket checks the required level before calling the action. This example prints the request; connect run to your transport to send it.
Keep key={inquiry.id} so a new inquiry starts with its own draft. The countdown stops at zero, but status and permissions remain as supplied. Your app updates them from the venue. Shortcuts require a HotkeysProvider, shown below.
Buyer, seller, and two-way inquiries
The client's side determines your fields: an offer for a buyer, a bid for a seller, or both for a two-way inquiry. Change the client side to load a different keyed inquiry and clear the previous draft. The market stays the same so the field choice is easy to compare.
Both sides are required for a two-way quote. Enter a bid above the offer to see the crossed-quote check; the request caption changes only after a valid Quote action.
import { useState } from "react"
import { RfqTicket, describeQuote, type RfqAction, type RfqInquiry } from "@/components/rfq-ticket"
function inquiryFor(side: RfqInquiry["side"]): RfqInquiry {
const now = Date.now()
return {
id: `Q-${side}`,
instrument: { symbol: "T10", description: "10Y Treasury", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 } },
client: { name: "ALPHA" },
side,
quantity: 5_000_000,
receivedAt: now,
expiresAt: now + 60_000,
market: { bid: 99.5, ask: 99.515625 },
status: "Open",
allowedActions: ["quote"],
}
}
export default function RfqTicketSidesDemo() {
const [inquiry, setInquiry] = useState(() => inquiryFor("buy"))
const [request, setRequest] = useState("None")
const actions: RfqAction[] = [
{ id: "quote", label: "Quote", run: (draft, inquiry) => setRequest(describeQuote(draft, inquiry)) },
]
return (
<>
<div data-demo-controls className="text-xs">
<label className="flex items-center gap-2">
Client side
<select className="rounded border bg-background px-2 py-1" value={inquiry.side} onChange={(event) => {
setInquiry(inquiryFor(event.target.value as RfqInquiry["side"]))
setRequest("None")
}}>
<option value="buy">Buyer</option>
<option value="sell">Seller</option>
<option value="two-way">Two-way</option>
</select>
</label>
</div>
<div className="w-[26rem] max-w-full space-y-2 text-xs lining-nums tabular-nums">
<RfqTicket key={inquiry.id} inquiry={inquiry} actions={actions} />
<p role="status" className="text-muted-foreground">Last request: {request}</p>
</div>
</>
)
}
Suggested levels and shortcuts
The Auto button copies suggested levels into the draft without sending. Choose a quick size, then Quote; the request caption includes that quantity. The venue supplies the allowed sizes, which can include more than the original inquiry's size. This example also shows client details, venue tags, settlement, and preformatted risk context.
Inside a quote field, use mod+shift+a for suggestions, mod+up or mod+down to step the focused level, mod+1 or mod+2 for the first two quick sizes, and mod+enter to quote. mod is Command on Mac and Control elsewhere. HotkeysProvider enables these bindings and the send hint; plain Enter sends nothing.
import { useState } from "react"
import { RfqTicket, describeQuote, type RfqAction, type RfqInquiry } from "@/components/rfq-ticket"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
export default function RfqTicketShortcutsDemo() {
const [inquiry] = useState<RfqInquiry>(() => {
const now = Date.now()
return {
id: "Q-keys",
instrument: { symbol: "T10", description: "10Y Treasury", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 } },
client: { name: "ALPHA", tier: "Tier 1", trader: "J. Doe" },
tags: ["RFQ", "3 dealers"],
settlement: "T+1",
context: [{ label: "Position", value: "−12mm", tone: "down" }, { label: "DV01", value: "$4.2K" }],
side: "two-way",
quantity: 5_000_000,
receivedAt: now,
expiresAt: now + 60_000,
market: { label: "Composite", bid: 99.5, ask: 99.515625 },
suggested: { bid: 99.484375, ask: 99.53125 },
status: "Open",
allowedActions: ["quote"],
}
})
const [request, setRequest] = useState("None")
const actions: RfqAction[] = [
{ id: "quote", label: "Quote", run: (draft, inquiry) => setRequest(describeQuote(draft, inquiry)) },
]
return (
<HotkeysProvider bindings={[]}>
<div className="w-[26rem] max-w-full space-y-2 text-xs lining-nums tabular-nums">
<RfqTicket key={inquiry.id} inquiry={inquiry} actions={actions} quickSizes={[1_000_000, 2_000_000, 10_000_000]} />
<p role="status" className="text-muted-foreground">Last request: {request}</p>
</div>
</HotkeysProvider>
)
}
Server responses
The controls above the ticket stand in for venue messages. Quote enters Sending; Acknowledge quote supplies the live levels and changes acknowledged to flash the ticket. Report done away supplies the terminal status and removes allowed actions. These transitions wait for you so each state remains inspectable.
Quote auto uses a fixed server price without reading or validating the fields. Pass needs no quote either and is available while waiting or quoted. Pass, Expire inquiry, and Next inquiry discard the pending request, so an acknowledgement cannot revive an ended inquiry or land on the next one. Next inquiry also resets the keyed draft and countdown. There are no delayed callbacks.
Let the timer reach zero to see that it leaves actions unchanged, then use Expire inquiry to supply the venue's end status. The ticket displays the status and permissions it receives; it does not infer either from the clock.
import { useRef, useState } from "react"
import { RfqTicket, type RfqAction, type RfqInquiry, type RfqQuoteDraft } from "@/components/rfq-ticket"
function fresh(id: string): RfqInquiry {
const now = Date.now()
return {
id,
instrument: { symbol: "T10", description: "10Y Treasury", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 } },
client: { name: "ALPHA" },
side: "buy",
quantity: 5_000_000,
receivedAt: now,
expiresAt: now + 30_000,
market: { bid: 99.5, ask: 99.515625 },
status: "Open",
allowedActions: ["quote", "quote-auto", "pass"],
}
}
export default function RfqTicketServerDemo() {
const [inquiry, setInquiry] = useState(() => fresh("Q-1"))
const [pending, setPending] = useState<RfqQuoteDraft | null>(null)
const [acknowledged, setAcknowledged] = useState(0)
const nextId = useRef(2)
const send = (draft: RfqQuoteDraft) => {
setPending(draft)
setInquiry((current) => ({ ...current, status: "Sending", allowedActions: ["pass"], message: "Waiting for the venue." }))
}
const acknowledge = () => {
if (!pending || pending.inquiryId !== inquiry.id) return
setInquiry((current) => ({ ...current, status: "Quoted", quoted: { bid: pending.bid, ask: pending.ask }, allowedActions: ["quote", "pass"], message: "Quote acknowledged by the venue." }))
setAcknowledged((value) => value + 1)
setPending(null)
}
const finish = (status: string, message: string) => {
setPending(null)
setInquiry((current) => ({ ...current, status, message, allowedActions: [] }))
}
const actions: RfqAction[] = [
{ id: "quote", label: "Quote", run: send, primary: true },
// A fixed server price stands in for an auto-quoter; it does not read the fields.
{ id: "quote-auto", label: "Quote auto", needsQuote: false, run: (_, inquiry) => send({ inquiryId: inquiry.id, bid: null, ask: 99.53125, quantity: inquiry.quantity }) },
{ id: "pass", label: "Pass", needsQuote: false, destructive: true, run: () => finish("Passed", "Inquiry passed.") },
]
const next = () => {
setPending(null)
setAcknowledged(0)
setInquiry(fresh(`Q-${nextId.current++}`))
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!pending} onClick={acknowledge}>Acknowledge quote</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={inquiry.status !== "Quoted"} onClick={() => finish("Done away", "Cover 99-16+")}>Report done away</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!inquiry.allowedActions?.length} onClick={() => finish("Expired", "The venue ended this inquiry.")}>Expire inquiry</button>
<button type="button" className="rounded border px-2 py-1" onClick={next}>Next inquiry</button>
</div>
<div className="w-[26rem] max-w-full space-y-2 text-xs lining-nums tabular-nums">
<RfqTicket key={inquiry.id} inquiry={inquiry} actions={actions} acknowledged={acknowledged} />
<p role="status" className="text-muted-foreground">Inquiry {inquiry.id}: {inquiry.status}. Acknowledgements: {acknowledged}.</p>
</div>
</>
)
}
API Reference
This block installs rfq-ticket.tsx in your components alias. It shares source files with quote-field, countdown, format, flash-cell, and use-hotkeys at a given registry version. Locally edited or older installed files may differ.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
inquiry |
RfqInquiry |
Required | Server data for this ticket. |
actions |
readonly RfqAction[] |
Required | Available action definitions, in display order. |
defaultDraft |
Partial<RfqLevels> |
Both levels null |
Initial bid and ask; read only on mount. |
onDraftChange |
(draft: RfqQuoteDraft) => void |
None | Reports the current draft after a change. |
acknowledged |
unknown |
undefined |
Change this value when the server acknowledges a quote. |
autoFocus |
boolean |
false |
Requests focus on the first quote field on mount. |
disabled |
boolean |
false |
Disables fields and buttons and blocks action execution; see Keys for draft shortcuts. |
hotkeys |
boolean |
true |
Declares missing bindings in the hotkey registry. |
limits |
Limits |
None | Checks quoted sides before sending. |
quickSizes |
readonly number[] |
None | Alternative quote quantities in raw notional or contracts. |
labels |
Partial<RfqTicketLabels> |
DEFAULT_RFQ_TICKET_LABELS |
Overrides the ticket's own wording. |
className |
string |
None | Styles the outer group. |
The inquiry
RfqInquiry field |
Type | Required / default | Meaning |
|---|---|---|---|
id |
string |
Required | Inquiry identity. |
instrument |
RfqInstrument |
Required | Symbol, optional description, and required InstrumentConvention. |
side |
"buy" | "sell" | "two-way" |
Required | Client's side. |
quantity |
number |
Required | Raw notional or contract count. |
expiresAt |
number |
Required | Deadline in milliseconds since the epoch. |
status |
string |
Required | Venue text, printed in a badge and as data-status. |
client |
RfqClient |
Name: "A client" |
Required name; optional tier, trader, and salesperson, all strings. |
tags |
readonly string[] |
None | Venue badges, such as protocol, dealer count, or list name. |
settlement |
string |
None | Settlement text. |
receivedAt |
number |
First countdown render | Arrival in milliseconds since the epoch; sets the countdown bar's full duration. |
context |
readonly RfqContextItem[] |
None | Preformatted label and value strings, with optional tone: "up" | "down" | "flat". |
market |
RfqMarket |
None | Reference bid, ask, and optional mid; optional label defaults to "Market". |
quoted |
RfqLevels |
None | This desk's live quote from the server. |
suggested |
RfqLevels |
None | Proposed levels to copy into the draft. |
message |
string |
None | Server text below the actions. |
allowedActions |
readonly string[] |
No actions | Ids matched against actions. |
The headline reads Client A buys 5mm T 4 1/8 05/15/34. instrument.description falls back to instrument.symbol. Size prints in millions of notional, or as a count when convention.quantityUnit is "contracts". Tier and tags appear as badges; trader, salesperson, and settlement appear beneath the headline. Context values print unchanged, with tone applied only when supplied.
RfqLevels has optional bid and ask fields of type number | null. RfqMarket adds label?: string and mid?: number | null. A mid can seed a blank field but is not displayed as a bid or ask.
Client's side |
Quote fields |
|---|---|
"buy" |
Offer (ask) |
"sell" |
Bid (bid) |
"two-way" |
Bid and Offer |
The quote
Each quote-field parses and steps in the instrument's convention: 99-16+ for a fractional note price or 4.253 for a bill on discount. Arrows and step buttons use the convention's step. A blank field starts from the market's same side, then the suggested same side, then the other market side, then market.mid. With no starting level, stepping does nothing.
Distance below a field compares it with the market's same side: ticks for price, basis points for yield, discount, or spread. For example, one tick above shows +1 vs market. Missing or nonfinite levels show no distance. Yield and discount differences multiply by 100; spreads are already in basis points.
The suggested-level button appears when at least one requested side has a suggestion. It and rfq.suggested copy available suggestions without clearing other fields or sending a quote. Updating quoted or suggested does not automatically replace the draft.
RfqQuoteDraft field |
Type | Initial value |
|---|---|---|
inquiryId |
string |
inquiry.id |
bid, ask |
number | null |
Corresponding defaultDraft level, otherwise null |
quantity |
number (optional in the type) |
inquiry.quantity; the ticket includes it in emitted drafts |
onDraftChange reports changes to this draft after render. It does not report the initial draft, unchanged values, or inquiry-only updates. Changing defaultDraft later has no effect; remount to reset it.
These helpers are exported for confirmations, palette rows, and tests:
| Helper | Result |
|---|---|
quotedSides(side) |
Requested dealer sides as readonly QuoteSide[], where QuoteSide is "bid" | "ask". |
formatSize(quantity, convention) |
Millions of notional (5_000_000 → 5mm) or a contract count. |
quoteDistance(level, market, convention) |
{ value, text }, or null for a missing or nonfinite input. Both levels accept number | null | undefined. |
checkQuote(draft, inquiry, labels?) |
RfqQuoteProblems: optional bid and ask messages for required null levels or a bid above the ask. |
describeQuote(draft, inquiry, labels?) |
Text such as Offer 5mm T 4 1/8 05/15/34 @ 99-16+; uses draft.quantity ?? inquiry.quantity. |
The last two helpers default to DEFAULT_RFQ_TICKET_LABELS and accept a full RfqTicketLabels object. checkQuote does not check limits, quantity, or finiteness; it rejects crossed levels whenever both are non-null, including an unused side supplied through defaultDraft.
The actions are the server's
RfqAction field |
Type | Required / default | Purpose |
|---|---|---|---|
id |
string |
Required | Matches an allowedActions id. |
label |
string | ((draft: RfqQuoteDraft) => string) |
Required | Button text, optionally derived from the draft. |
run |
(draft: RfqQuoteDraft, inquiry: RfqInquiry) => void |
Required | Executes the action with the current draft and inquiry. |
needsQuote |
boolean |
true |
Requires quote and limit checks. |
destructive |
boolean |
false |
Uses the destructive button style. |
primary |
boolean |
Automatic | Selects the action for rfq.send; uses primary styling unless destructive. |
Only matching actions render, in your actions order. No matches produces the nothing-allowed message. The primary action is the first allowed action marked primary, otherwise the first that needs a quote, otherwise the first allowed action.
On execution, the ticket rechecks disabled, permission, and any required quote and limit checks against current props. Quote errors appear under their fields. Set needsQuote: false for pass, stop, or server-priced actions; these receive the current draft without quote or limit validation.
Fields, size buttons, and the suggestion button are disabled unless an allowed action needs a quote and disabled is false. Removing allowed actions preserves the displayed draft. status alone does not disable anything, and countdown expiry does not remove actions.
Changing acknowledged triggers the primary ring flash; the first render and equal values do not. Changes use Object.is equality. The flash follows useFlash motion behavior and never changes the inquiry's status.
Arrival never moves anything
The parent chooses the active inquiry and owns focus. Key the ticket by inquiry id: reusing a mounted ticket for another inquiry retains the old draft, including its inquiryId and quantity. autoFocus acts only on mount and cannot focus a disabled field.
The countdown uses receivedAt to expiresAt for its bar, falling back to first render when receivedAt is omitted. It announces the label and time left on tier changes, including the provisional last-10-seconds threshold and expiry. Your app decides whether the inquiry has ended from venue status.
Keys
Inside a HotkeysProvider, the ticket declares these editing bindings when their ids are missing:
| Binding | Default key | Action |
|---|---|---|
rfq.send |
mod+enter |
Runs the primary action. |
rfq.tick-up, rfq.tick-down |
mod+up, mod+down |
Steps the focused quote field, or the first field. |
rfq.suggested |
mod+shift+a |
Copies suggested levels. |
rfq.size-1 … rfq.size-9 |
mod+1 … mod+9 |
Selects an entry from quickSizes, in supplied order. |
mod is Command on Mac and Control elsewhere. QUICK_SIZE_KEYS exports the nine size shortcuts. Spread RFQ_TICKET_BINDINGS into your own registry list to change keys or descriptions. Tickets share declarations; the last declaring ticket's unmount removes only ticket-owned bindings. Handlers are scoped to each ticket's inner box, and dialog boundaries keep outside handlers from running.
hotkeys={false} skips declarations but still binds handlers for ids already in the registry. Without a provider, there are no shortcuts or key hints. Plain Enter sends nothing; the ticket has no form.
Draft shortcuts have narrower guards than the controls: step and suggestion shortcuts can change the draft while fields are disabled. Quick-size shortcuts check disabled but do not check allowed actions. Sending still checks both. Use registry binding conditions to suppress draft shortcuts when needed.
Limits
The limits check receives only requested bid and ask levels, plus the inquiry's market and convention. It receives no quantity, so maxQuantity and minQuantity do not constrain RFQ quick sizes. Custom rules receive that same limited draft.
maxDistance compares each level with the market's same side, falling back to market.mid when that side is absent. Use ticks for price and basis points for other quote bases; a spread is compared in the basis points it is quoted in, the same distance the field shows. sides checks the dealer's side: a bid is a buy and an offer is a sell.
A block shows under its field, or below the actions for other fields, and disables actions that need a quote. A confirm applies to any action that needs a quote: its button becomes Quote anyway? (using that action's label), the reason appears below, and the next activation of the same action sends if checks pass. Editing a level or choosing a size clears the confirmation. Both checks run live and again on execution; needsQuote: false actions bypass them.
Quick sizes
quickSizes={[1_000_000, 2_000_000]} adds For 5mm 1mm 2mm beneath a 5mm inquiry's fields. The inquiry's size comes first; matching entries are removed from the displayed alternatives. Shortcuts still index your original array. Sizes use the convention's unit, and the selected button has aria-pressed="true".
Choosing a size changes the draft's quantity. Supply sizes the venue accepts; the ticket does not restrict them to less than the inquiry's size. An absent or empty array hides the row. Removing it later retains the selected draft quantity.
Labels
labels overrides entries in DEFAULT_RFQ_TICKET_LABELS: side words, field and reference labels, quote-check messages, the nothing-allowed message, and confirmation text (anyway, with an {action} placeholder). Action labels and server text come from their own inputs. Limit messages, quote-field internal wording, and hotkey descriptions are separate from this prop.
The group and timer are both named Inquiry Q-1 by default, using labels.ticket and the inquiry id. Fields have visible labels and use aria-invalid when an error is shown.
Tokens
The install includes up, down, and flat tokens and their soft variants, plus expiring and expiring-soft for the countdown. Context tones use up, down, and muted-foreground for flat.
Built on shadcn's badge, button, field, input-group, and kbd.
This page is docs/rfq-ticket.md at v1.4.13.