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#v2.0.0
npx shadcn@latest add tradecn/ui/rfq-ticket#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/rfq-ticket#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/rfq-ticket#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add 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, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } 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, quoteInvertedOf, stepQuote, ticksBetween, type InstrumentConvention } from "@/lib/format" import { formatKeys, type HotkeyBinding, type HotkeyRegistry } from "@/lib/hotkeys" import { blocks, checkLimits, confirms, problemsByField, type Limits, type LimitsLabels, type Problem as LimitProblem } from "@/lib/limits" import { Countdown } from "@/components/ui/countdown" import { QuoteField } from "@/components/ui/quote-field" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // 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 /** Null on a side the client did not ask for. */ bid: number | null ask: number | null /** The size the quote is for: the inquiry's as it stands, a resize included, 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 /** Preferred by `rfq.send` among quote-sending actions; a `needsQuote: false` action never runs on the key. */ 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 /** The invalid-text both level fields show. */ invalidLevel?: 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 /** A size's unit where the convention quotes notional. */ millions: string /** A distance's unit where the convention quotes a yield, discount, or spread. */ bp: string /** What a screen reader hears after a context value with an up or a down tone. */ toneUp: string toneDown: string /** The level fields' step buttons, `{label}` the field's label. */ stepUp: string stepDown: 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", invalidLevel: "Not a level in this instrument's notation.", 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 quote is crossed.", nothingAllowed: "Nothing can be done with this inquiry right now.", for: "for", anyway: "{action} anyway?", millions: "mm", bp: "bp", toneUp: "up", toneDown: "down", stepUp: "{label} up one step", stepDown: "{label} down one step", } /** `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 as registry defaults your own registration shadows. */ 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 step", group: "Inquiry" }, { id: "rfq.tick-down", keys: "mod+down", scope: "editing", description: "Level down one step", 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, in `millions` as the unit, or a count of contracts. */ export function formatSize(quantity: number, convention: InstrumentConvention, millions = "mm"): string { return convention.quantityUnit === "contracts" ? formatQuantity(quantity) : formatNotional(quantity, { unit: "mm" }).replace(/mm$/, millions) } export interface RfqQuoteProblems { bid?: string ask?: string } /** What stops a quote from being sent: a needed side that is blank or non-finite, or, on a two-way, a crossed pair of finite levels read through the instrument's quote direction — bid above offer normally, bid below offer where a higher quote means a lower price, as `quoteInvertedOf` reads it. A side the client did not ask for is never checked. 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) // Levels read through level(): a non-finite value counts as absent here too, as the page // promises for every reader of a level. const bid = level(draft.bid) const ask = level(draft.ask) if (sides.includes("bid") && bid === null) problems.bid = labels.bidNeeded if (sides.includes("ask") && ask === null) problems.ask = labels.askNeeded // A crossed quote bids above its offer, unless a higher quote means a lower price. A // declared quoteInverted decides for any basis; the defaults are yield and discount // inverted, price and spread not, since CDS quotes bid below offer while cash credit // quotes the other way. const inverted = quoteInvertedOf(inquiry.instrument.convention) if (sides.length === 2 && bid !== null && ask !== null && (inverted ? bid < ask : bid > 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, labels.millions)} ${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, in `bp` as the unit. Null when a side is missing. */ export function quoteDistance(level: number | null | undefined, market: number | null | undefined, convention: InstrumentConvention, bp = "bp"): { 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, unit: "" })} ${bp}` } } // Every ticket declares the bindings as registry defaults: the registry refcounts them, a // consumer registration of an id shadows its default, and unregistering surfaces it again, so // no mount order can delete a consumer's declaration or strand a remaining ticket without one. function declareBindings(registry: HotkeyRegistry, bindings: readonly HotkeyBinding[]): () => void { const releases = bindings.map((binding) => registry.declareDefault(binding)) return () => { for (const release of releases) release() } } const noop = () => () => {} // A limit's identity apart from its words, which the market can change under it. const ruleOf = (p: LimitProblem) => `${p.field}\u0000${p.rule}` // What a press met, as it landed: the question an action asked, or (no action) the blocks it was refused for, by rule. interface Said { text: string revision: number draft: RfqQuoteDraft | null action: string | null rules: readonly string[] } // A press counts once: the second click of a double-click, and the clicks a held Enter repeats on a focused // button, run nothing and answer no question the first press asked. The hold belongs to the button it repeats // on, and ends when the key is let go or focus leaves that button. function useFreshPress() { const held = useRef<EventTarget | null>(null) const release = () => { held.current = null } return { onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => { held.current = event.key === "Enter" && event.repeat ? event.currentTarget : null }, onKeyUp: release, onBlur: release, fresh: (event: ReactMouseEvent<HTMLElement>) => event.detail <= 1 && !(event.detail === 0 && held.current === event.currentTarget), } } /** The one action the send key runs: it sends the quote, so neither a pass nor a destructive action rides mod+enter. */ function sendTarget(allowed: readonly RfqAction[]): RfqAction | undefined { const sending = allowed.filter((action) => action.needsQuote !== false && !action.destructive) return sending.find((action) => action.primary) ?? sending[0] } const RFQ_CORE_BINDINGS = RFQ_TICKET_BINDINGS.filter((binding) => !binding.id.startsWith("rfq.size-")) const RFQ_SIZE_BINDINGS = RFQ_TICKET_BINDINGS.filter((binding) => binding.id.startsWith("rfq.size-")) const level = (v: number | null | undefined) => (typeof v === "number" && Number.isFinite(v) ? 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` as registry defaults. 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 /** The limit sentences' words, over `DEFAULT_LIMITS_LABELS`, whose `ask` reads `offer` here. */ limitsLabels?: Partial<LimitsLabels> /** Sizes the quote may be for besides the inquiry's, as buttons and `mod+1` to `mod+9`, printed in the convention's unit. Whole numbers above zero, each once; any other is left out. */ quickSizes?: readonly number[] labels?: Partial<RfqTicketLabels> className?: string } const TONE_CLASS = { up: "text-up", down: "text-down", flat: "text-muted-foreground" } as const /** The quick sizes a ticket offers: whole numbers above zero, each once, in order. */ const wholeSizes = (sizes: readonly number[] | undefined): readonly number[] => [...new Set((sizes ?? []).filter((size) => Number.isSafeInteger(size) && size > 0))] // The acknowledgement ring has no direction, whatever the value that rings it. const noDirection = () => null export function RfqTicket({ inquiry, actions, defaultDraft, onDraftChange, acknowledged, autoFocus = false, disabled = false, hotkeys: declareHotkeys = true, limits, limitsLabels: limitsLabelsProp, quickSizes, labels: labelsProp, className }: RfqTicketProps) { // The ticket calls the ask the offer, and so do its limit sentences. const limitsLabels = { ask: "offer", ...defined(limitsLabelsProp) } const labels = { ...DEFAULT_RFQ_TICKET_LABELS, ...defined(labelsProp) } const id = useId() const { convention } = inquiry.instrument const sides = quotedSides(inquiry.side) // The draft holds only the sides the client asked for, so a default for another side never reaches a check or a send. const seeded = (side: QuoteSide) => { const v = sides.includes(side) ? level(defaultDraft?.[side]) : null return v === null ? null : stepQuote(v, convention, 0) } const [levels, setLevels] = useState<{ bid: number | null; ask: number | null }>(() => ({ bid: seeded("bid"), ask: seeded("ask") })) // A quick size the dealer took, or null for the inquiry's own size as it stands, so a resize under the same id is // what the quote is for. const [size, setSize] = useState<number | null>(null) const quantity = size ?? inquiry.quantity // Only the sides the inquiry asks for now: a level typed for a side it no longer asks for stays out of the draft. const asksBid = sides.includes("bid") const asksAsk = sides.includes("ask") const draft = useMemo<RfqQuoteDraft>(() => ({ inquiryId: inquiry.id, bid: asksBid ? levels.bid : null, ask: asksAsk ? levels.ask : null, quantity }), [inquiry.id, levels, quantity, asksBid, asksAsk]) const sizes = useMemo(() => wholeSizes(quickSizes), [quickSizes]) const [problems, setProblems] = useState<RfqQuoteProblems>({}) // The action a limit asked about: a fresh press on it sends, unless the market has added a reason since, which asks // again. Any change to the draft withdraws the question: a level, a quick size, or the inquiry's resize. const [confirming, setConfirming] = useState<string | null>(null) const press = useFreshPress() const box = useRef<HTMLDivElement>(null) const inputs = { bid: useRef<HTMLInputElement>(null), ask: useRef<HTMLInputElement>(null) } const allowed = actions.filter((action) => inquiry.allowedActions?.includes(action.id)) // A question whose action the venue took away no longer stands: if the action comes back, it asks again. if (confirming !== null && !allowed.some((action) => action.id === confirming)) setConfirming(null) const primary = allowed.find((action) => action.primary) ?? allowed.find((action) => action.needsQuote !== false) ?? allowed[0] // The key hint rides the action the send key actually runs, which can differ from primary. const sendAction = sendTarget(allowed) // The fields are live while some allowed action would send what is in them. const quoting = !disabled && allowed.some((action) => action.needsQuote !== false) const latest = useRef({ onDraftChange, actions, inquiry, draft, labels, disabled, limits, limitsLabels, confirming, quickSizes: sizes, quoting, problems }) // Layout phase, not passive: a keydown can land between the commit that withdrew an action // and the passive effects, and the check at run time must see what the dealer sees. useLayoutEffect(() => { latest.current = { onDraftChange, actions, inquiry, draft, labels, disabled, limits, limitsLabels, confirming, quickSizes: sizes, quoting, problems } }) // When the control under focus leaves — quoting stops and both fields disable, a sent // action's button unmounts — focus falls to body, outside every fence, and the shortcuts go // dead. The scope root takes it instead. const focusedInside = useRef<HTMLElement | null>(null) useLayoutEffect(() => { const node = box.current ? box.current.parentElement ?? box.current : null const previous = focusedInside.current if (!node || !previous) return const doc = node.ownerDocument const gone = !previous.isConnected || previous.matches(":disabled") if (gone && (doc.activeElement === previous || doc.activeElement === doc.body)) { // Once parked, the record is spent: a later commit must not take focus again, and a // sibling ticket's stale record must not outrank the one the user was in. Never // scroll: the park can fire while the dealer reads elsewhere. focusedInside.current = null node.focus({ preventScroll: true }) } }) // 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, with no direction even when what rings it is a // count. useFlash(box, acknowledged, { variant: "ring", color: "var(--primary)", compare: noDirection }) /** The size the quote is for: the inquiry's own, which follows a resize, or the n-th quick size (from 1). */ function setQuantity(next: number) { setSize(next === latest.current.inquiry.quantity ? null : next) setConfirming(null) } function quick(n: number) { const next = latest.current.quickSizes[n - 1] if (next !== undefined && latest.current.quoting) setQuantity(next) } function setLevel(side: QuoteSide, value: number | null) { setLevels((d) => (d[side] === value ? d : { ...d, [side]: value })) // A new level answers its own problem and a crossed pair's, whose message sits on the offer. const crossed = latest.current.labels.crossed setProblems((p) => (p[side] || p.ask === crossed ? { ...p, [side]: undefined, ...(p.ask === crossed ? { ask: 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, labels: limitsLabels }) : [] const blocking = blocks(limitProblems) const blockedBy = problemsByField(blocking) const blocked = blocking.length > 0 // A block holds an action that sends a quote without taking it out of reach: it looks disabled and stays focusable, // and a press on it, from any input, is refused and says why. const heldByLimit = (action: RfqAction) => blocked && action.needsQuote !== false const shownProblems = { bid: problems.bid ?? blockedBy.bid, ask: problems.ask ?? blockedBy.ask } // A block with no field drawn for it, a custom rule's on a side the client did not ask for included, shows on the // limits line, so whatever holds Quote is on screen. const otherBlocks = blocking.filter((p) => !(sides as readonly string[]).includes(p.field)) const asking = confirming !== null ? confirms(limitProblems) : [] const limitsText = [...otherBlocks, ...asking].map((p) => p.message).join(" ") // What a screen reader hears from the limits: what a press met, as it landed — the blocks it was refused for, or // the question it asked. Nothing is said as the market or the dealer's typing rewords them; the line and the // fields keep the live words on screen. A message goes for good once the draft changes or what it said no longer // stands (a block it named stops blocking, or the question is answered or withdrawn), so a block the market brings // back waits for the next press. Each message is a new node, so the same words said again are still heard. const [said, setSaid] = useState<Said>({ text: "", revision: 0, draft: null, action: null, rules: [] }) const blockingRules = new Set(blocking.map(ruleOf)) const stands = said.draft === draft && (said.action === null ? said.rules.every((rule) => blockingRules.has(rule)) : confirming === said.action) if (said.draft !== null && !stands) { // A question asked of another draft goes with its words: a resize under the same id changes the draft and no // control, so nothing else withdraws it. if (said.action !== null && said.draft !== draft && confirming === said.action) setConfirming(null) setSaid({ ...said, text: "", draft: null, action: null, rules: [] }) } const heard = stands ? said : null // The reasons the standing question asked about: a reason the market adds since makes the next press ask again. const askedRules = useRef<ReadonlySet<string>>(new Set()) /** Where a step starts when a field is blank: the market's same side, the suggested level, the market's other side, then its mid. */ 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 // The same side is blank here, so at most one market side remains. return level(market.bid) ?? level(market.ask) ?? level(market.mid) } function step(side: QuoteSide, steps: number) { const from = level(latest.current.draft[side]) ?? stepFrom(side) if (from === null) return setLevel(side, stepQuote(from, convention, steps)) } /** The side the key came from: the event's target, else focus in this ticket's own document — a popout runs in the opener's JavaScript, so the global document never holds its fields — a side's step buttons counting as the side; else the first side the client asked for. */ function focusedSide(target?: EventTarget | null): QuoteSide { // A popout's nodes come from another realm, where instanceof Element fails; nodeType is // realm-proof. const isElement = (node: EventTarget | null | undefined): node is Element => typeof node === "object" && node !== null && (node as Node).nodeType === 1 const from = isElement(target) ? target : (box.current?.ownerDocument ?? (typeof document === "undefined" ? null : document))?.activeElement ?? null for (const side of sides) { const input = inputs[side].current if (!input) continue if (from === input || (isElement(from) && input.closest("[data-slot='tradecn-quote-field']")?.contains(from))) 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]) // Snapped to the quote grid: the button and the field print the grid value, so that is what sends. if (value !== null) setLevel(side, stepQuote(value, convention, 0)) } } 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, limitsLabels: lineWords, confirming: asked, problems: shown } = 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, and a fresh // press on the same action sends unless the market has added a reason since. 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, labels: lineWords }) : [] const blocking = blocks(over) setProblems({ bid: found.bid, ask: found.ask }) // A field's new problem is an alert under it. One it already shows holds the same words, which a screen reader // does not hear again, so the announcer says it, beside the blocks. const again = (["bid", "ask"] as const).filter((side) => found[side] !== undefined && found[side] === shown[side]).map((side) => found[side]!) const refused = [...again, ...blocking.map((p) => p.message)] if (refused.length) setSaid((prev) => ({ text: refused.join(" "), revision: prev.revision + 1, draft: current, action: null, rules: blocking.map(ruleOf) })) if (found.bid || found.ask || blocking.length) return const reasons = confirms(over) if (reasons.length && (asked !== action.id || !reasons.every((p) => askedRules.current.has(ruleOf(p))))) { askedRules.current = new Set(reasons.map(ruleOf)) setConfirming(action.id) setSaid((prev) => ({ text: reasons.map((p) => p.message).join(" "), revision: prev.revision + 1, draft: current, action: action.id, rules: [] })) return } } setConfirming(null) action.run(current, now) } // Keys: defaults declared per ticket, handlers fenced to this ticket so another's keys stay its own. const registry = useMaybeHotkeys() const handlers = useRef({ send: () => {}, up: (target?: EventTarget | null) => { void target }, down: (target?: EventTarget | null) => { void target }, suggested: () => {}, quick: (n: number) => { void n } }) useLayoutEffect(() => { handlers.current = { send: () => { const { actions: list, inquiry: now, disabled: locked } = latest.current if (locked) return // The send key runs only an action that sends the quote — the same selection the key // hint renders — so a heading click and mod+enter can never fall back to a pass. const first = sendTarget(list.filter((action) => now.allowedActions?.includes(action.id))) if (first) run(first) }, // The draft moves only while the fields are live: the same quoting condition that // enables the controls, so a pass-only inquiry's keys change nothing either. up: (target?: EventTarget | null) => { if (latest.current.quoting) step(focusedSide(target), 1) }, down: (target?: EventTarget | null) => { if (latest.current.quoting) step(focusedSide(target), -1) }, suggested: () => { if (latest.current.quoting) takeSuggested() }, quick, } }) const quickCount = sizes.length useEffect(() => { if (!registry) return const release = declareHotkeys ? declareBindings(registry, RFQ_CORE_BINDINGS) : noop() // Fenced to the scope root, so the shortcuts run from the heading, the market, and the padding too. const within = { scope: "editing", element: () => (box.current ? box.current.parentElement ?? box.current : null) } const guard = (fn: (event: KeyboardEvent) => void) => (event: KeyboardEvent) => { event.preventDefault() fn(event) } const unbind = [ registry.bind("rfq.send", guard(() => handlers.current.send()), within), registry.bind("rfq.tick-up", guard((event) => handlers.current.up(event.target)), within), registry.bind("rfq.tick-down", guard((event) => handlers.current.down(event.target)), within), registry.bind("rfq.suggested", guard(() => handlers.current.suggested()), within), // Fenced handlers for all nine, acting only on a present size: an id anyone declares in // the editing scope meets this fence, and an undeclared absent size's event passes // through untouched. ...RFQ_SIZE_BINDINGS.map((binding, i) => registry.bind(binding.id, (event) => { if (i >= latest.current.quickSizes.length) return event.preventDefault() handlers.current.quick(i + 1) }, within)), ] return () => { for (const u of unbind) u() release() } }, [registry, declareHotkeys]) // The sizes declare only for the quick sizes passed, in their own effect, so a size-count // change never re-declares the core bindings and an absent id never stands fenceless. useEffect(() => { if (!registry || quickCount === 0) return const release = declareHotkeys ? declareBindings(registry, RFQ_SIZE_BINDINGS.slice(0, quickCount)) : noop() return release }, [registry, declareHotkeys, quickCount]) 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 sizeText = formatSize(inquiry.quantity, convention, labels.millions) 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 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-foreground/60 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" onFocusCapture={(event) => { focusedInside.current = event.target as HTMLElement }} onBlurCapture={(event) => { // Focus moving somewhere outside the ticket on purpose: the leaving control is still // in the document and enabled, so there is nothing to recover from. A window switch // also blurs with no destination, but the document loses focus with it — the record // stays, so a control withdrawn while the dealer is away still parks on return. const leaving = event.target as HTMLElement const next = event.relatedTarget as HTMLElement | null if (!next && !event.currentTarget.ownerDocument.hasFocus()) return if (focusedInside.current === leaving && leaving.isConnected && !leaving.matches(":disabled") && (!next || !event.currentTarget.contains(next))) focusedInside.current = null }}> <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=""> {sizeText} </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 role="status" variant="secondary" className="h-5 px-1.5 text-xs 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> {/* The tone is a hint in color, so it is also on the value as `data-direction` and said in words. */} <dd className={cn("font-medium", item.tone && TONE_CLASS[item.tone])} data-direction={item.tone}> {item.value} {(item.tone === "up" || item.tone === "down") && <span className="sr-only">, {item.tone === "up" ? labels.toneUp : labels.toneDown}</span>} </dd> </div> ))} </dl> )} {/* A table, so a screen reader reads each level with its row and side. */} {showMarket && ( <table className={cn("w-full border-collapse", numericFontClass(convention))} data-rfq-market data-numeric=""> <thead> <tr> <td /> {sides.map((side) => ( <th key={side} scope="col" className="py-px pl-3 text-right font-normal text-muted-foreground"> {labels[side]} </th> ))} </tr> </thead> <tbody> {market && ( <tr> <th scope="row" className="w-px py-px text-left font-normal whitespace-nowrap text-muted-foreground"> {marketLabel} </th> {sides.map((side) => ( <td key={side} className="py-px pl-3 text-right" data-rfq-market-level={side}> {formatQuote(level(market[side]), convention)} </td> ))} </tr> )} {quotedLevels && ( <tr> <th scope="row" className="w-px py-px text-left font-normal whitespace-nowrap text-muted-foreground"> {labels.quoted} </th> {sides.map((side) => ( <td key={side} className="py-px pl-3 text-right font-semibold" data-rfq-quoted={side}> {formatQuote(level(quotedLevels[side]), convention)} </td> ))} </tr> )} </tbody> </table> )} <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, labels.bp) 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)} invalidText={labels.invalidLevel} disabled={!quoting} error={shownProblems[side]} announceError={problems[side] !== undefined} labels={{ stepUp: labels.stepUp, stepDown: labels.stepDown }} 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> {sizes.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, ...sizes.filter((one) => one !== inquiry.quantity)].map((one) => ( <Button key={one} 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={quantity === one} aria-label={`${labels.quoteFor} ${formatSize(one, convention, labels.millions)}`} data-quick-size={one} onClick={() => setQuantity(one)}> {formatSize(one, convention, labels.millions)} </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" // A destructive action says so in the destructive color on the outline button, which holds 4.5 to 1 // at rest and turns to the foreground under the pointer: the destructive variant puts that color on // its own tint, below it. variant={action === primary && !action.destructive ? "default" : "outline"} size="sm" className={cn("h-7 gap-2 aria-disabled:opacity-50", action.destructive && "text-destructive")} disabled={disabled} aria-disabled={heldByLimit(action) || undefined} data-action={action.id} data-destructive={action.destructive || undefined} data-confirming={confirming === action.id || undefined} onKeyDown={press.onKeyDown} onKeyUp={press.onKeyUp} onBlur={press.onBlur} onClick={(event) => { if (press.fresh(event)) 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 === sendAction && sendKeys && ( <KbdGroup aria-hidden> {formatKeys(sendKeys)[0]?.map((cap) => ( <Kbd key={cap} className="text-xs">{cap}</Kbd> ))} </KbdGroup> )} </Button> )) )} </span> </div> {limitsText && ( <p className={asking.length ? "text-stale" : "text-destructive"} data-rfq-limits={asking.length ? "confirm" : "block"}> {limitsText} </p> )} <span aria-live="polite" aria-atomic="true" className="sr-only" data-rfq-announcer> {heard && <span key={heard.revision}>{heard.text}</span>} </span> {/* The server's second line, said as it changes: the region is in the page from the first render, so the first message is heard too, and the line a sighted dealer reads is hidden from a screen reader, which reads the region. */} <p role="status" className="sr-only" data-rfq-reply> {inquiry.message} </p> {inquiry.message && ( <p aria-hidden 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" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // 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 QuoteFieldLabels { /** The step buttons' names, `{label}` the field's label. */ stepUp: string stepDown: string } export const DEFAULT_QUOTE_FIELD_LABELS: QuoteFieldLabels = { stepUp: "{label} up one step", stepDown: "{label} down one step", } /** What the field says of text that is not a quote, unless `invalidText` says otherwise. */ export const DEFAULT_INVALID_QUOTE = "Not a quote in this instrument's notation." 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 and tied to the input. */ error?: string /** * False when you say `error` yourself: the field then shows it without an alert and without tying it to the input, * for an error whose words change on their own, such as a distance from a moving market, which would otherwise be * read again at every change. Default true. */ announceError?: boolean /** Said under the field when the text is not a quote. Default `DEFAULT_INVALID_QUOTE`. */ 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> labels?: Partial<QuoteFieldLabels> className?: string } export function QuoteField({ convention, value: rawValue, onValueChange, stepFrom = null, label, error, announceError = true, invalidText, placeholder, id: idProp, disabled = false, shiftMultiplier = 10, side, inputRef, labels: labelsProp, className }: QuoteFieldProps) { const basis = quoteBasisOf(convention) const word = label ?? QUOTE_BASIS_LABELS[basis] const labels = { ...DEFAULT_QUOTE_FIELD_LABELS, ...defined(labelsProp) } const generated = useId() const id = idProp ?? generated // A feed warming up hands out NaN, and nothing non-finite prints or steps: such a value is // no value. NaN also never equals itself, so comparing it raw would re-run the follow below // every render until React stops the loop. const value = rawValue !== null && Number.isFinite(rawValue) ? rawValue : null 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 (!Object.is(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 !== null && stepFrom !== undefined && Number.isFinite(stepFrom) ? stepFrom : null) if (from === null) 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 ?? DEFAULT_INVALID_QUOTE) : undefined) // The field's own reading of the text is always said; your error is, unless you say it yourself. const announced = error === undefined || announceError 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)} // The text keyboard, not the decimal pad: a phone's pad has no minus or plus, which a negative quote and 99-16+ need. autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={problem ? true : undefined} aria-describedby={problem && announced ? `${id}-error` : 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={labels.stepDown.replace("{label}", word)} disabled={disabled} data-step="-1" onClick={() => step(-1)}> − </InputGroupButton> <InputGroupButton type="button" size="icon-xs" aria-label={labels.stepUp.replace("{label}", word)} disabled={disabled} data-step="1" onClick={() => step(1)}> + </InputGroupButton> </InputGroupAddon> </InputGroup> {problem && ( <FieldError id={`${id}-error`} {...(announced ? {} : { role: "none" })}> {problem} </FieldError> )} </Field> </div> ) }components/ui/ countdown.tsx import { cn } from "cn" import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react" import { useNow } from "@/hooks/use-clock" import { sharedClock, type Clock } from "@/lib/clock" // Time left to a moment: digits that tick once a second and a bar that shrinks to nothing. // // The digits read the shared clock, so a stack of forty countdowns costs one interval. The bar is // one Web Animation on its element, started once and left to the compositor, so it costs nothing per // frame; under reduced motion it steps with the digits instead. The tier turns in the last seconds // and stops at zero. What the countdown never does is decide anything: it shows the time it was // given, and the server's own word is what says whether the thing it timed is over. export type CountdownTier = "plenty" | "soon" | "expired" export interface CountdownThresholds { /** From how far out the tier is `soon`. */ soonMs: number } /** A placeholder until the people who work the screen say how many seconds count as soon. */ export const PROVISIONAL_COUNTDOWN_THRESHOLDS: CountdownThresholds = { soonMs: 10_000 } /** Pure tiering. At or below zero is expired, whatever the thresholds. */ export function countdownTier(remainingMs: number, thresholds: CountdownThresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS): CountdownTier { if (!(remainingMs > 0)) return "expired" return remainingMs <= thresholds.soonMs ? "soon" : "plenty" } /** "1:30", "0:09", "0:00", and "1:02:03" past an hour. Rounds up, so it never says zero while time is left: an endless wait (`Infinity`) prints "–". */ export function formatRemaining(ms: number): string { if (ms === Infinity) return "–" const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0 const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 const minutes = h > 0 ? String(m).padStart(2, "0") : String(m) return `${h > 0 ? `${h}:` : ""}${minutes}:${String(s).padStart(2, "0")}` } const TIER_CLASS: Record<CountdownTier, string> = { plenty: "text-foreground", soon: "font-semibold text-expiring", expired: "text-muted-foreground", } const BAR_CLASS: Record<CountdownTier, string> = { plenty: "bg-primary", soon: "bg-expiring", expired: "bg-muted-foreground", } function prefersReducedMotion(): boolean { return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches } export interface CountdownProps { /** When it ends, in ms since the epoch. */ expiresAt: number /** When it began, for the bar's full width. Left out, the moment the countdown was first drawn. */ startsAt?: number thresholds?: CountdownThresholds clock?: Clock /** The name a screen reader gives it, and the subject of its announcements. Default "Time left". */ label?: string /** The digits alone, inline, for a grid cell. */ compact?: boolean /** Speak the time left when the tier changes, in a polite live region. Default true; turn it off in a grid, where the stack speaks. */ announce?: boolean /** Called once when the digits reach zero. A display event: the server's word says whether the thing timed is over. */ onExpire?: () => void className?: string } // The capability never changes within a session, so the store is three constants. const subscribeToNothing = () => () => {} const hasWebAnimations = () => typeof Element !== "undefined" && typeof Element.prototype.animate === "function" const optimistic = () => true export function Countdown({ expiresAt, startsAt, thresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS, clock, label = "Time left", compact = false, announce = true, onExpire, className }: CountdownProps) { const c = clock ?? sharedClock() const ticked = useNow(c) // First sight samples the time source directly: the shared clock's last tick can be // arbitrarily old when nothing subscribed while this countdown was away. The time is never // behind first sight, so the first frame shows the real time left and nothing is announced // when the clock catches up. const [firstSeen] = useState(() => c.sample?.() ?? ticked) const now = Math.max(ticked, firstSeen) const remaining = expiresAt - now const tier = countdownTier(remaining, thresholds) // The bar's full width is `startsAt` to `expiresAt`; without a finite `startsAt`, from first sight to the end. // With no finite deadline it does not move: full for an endless wait, empty for one it cannot read. const total = Math.max(1, expiresAt - (typeof startsAt === "number" && Number.isFinite(startsAt) ? startsAt : firstSeen)) const fraction = Number.isFinite(total) ? Math.max(0, Math.min(1, remaining / total)) : remaining > 0 ? 1 : 0 const [reduced] = useState(prefersReducedMotion) // A browser without Web Animations draws the bar from the digits' tick too, instead of holding it // full. The server snapshot is optimistic so hydration agrees, and the client reads the real API. const canAnimate = useSyncExternalStore(subscribeToNothing, hasWebAnimations, optimistic) // Drawn from the digits' tick when it cannot animate: reduced motion, no API, nothing left to animate, or no deadline to run to. const staticBar = reduced || !canAnimate || tier === "expired" || !Number.isFinite(expiresAt) const bar = useRef<HTMLSpanElement>(null) useLayoutEffect(() => { const el = bar.current if (!el || staticBar || typeof el.animate !== "function") return const left = Math.max(0, expiresAt - (c.sample?.() ?? c.now())) const from = Math.max(0, Math.min(1, left / total)) // One animation for exactly the time left, linear, held at zero when it ends. The // compositor runs it. When the fresh sample says time is already up while the last tick // has not caught up — the second right after a deadline — the zero-length run holds the // bar empty instead of leaving it full until the tick. const animation = el.animate([{ transform: `scaleX(${from})` }, { transform: "scaleX(0)" }], { duration: left, easing: "linear", fill: "forwards" }) return () => animation.cancel() }, [expiresAt, total, c, staticBar]) // Said once, when the digits reach zero; said again only if the end moves out and comes back. const latest = useRef(onExpire) useEffect(() => { latest.current = onExpire }) const fired = useRef(false) useEffect(() => { if (tier !== "expired") { fired.current = false return } if (fired.current) return fired.current = true latest.current?.() }, [tier]) // Derived state, set during render: speak when the tier changes, never on a plain tick. const [seen, setSeen] = useState(tier) const [message, setMessage] = useState("") if (seen !== tier) { setSeen(tier) setMessage(`${label} ${formatRemaining(remaining)}`) } // Named by the label and the time left together, so the digits reach a screen reader wherever the name is read, // a grid row's name built from its cells included. const id = useId() return ( <span role="timer" aria-labelledby={`${id}-label ${id}-digits`} data-slot="tradecn-countdown" data-tier={tier} // Soon, the full countdown sits on the expiring tint with foreground digits, which read there in every theme. className={cn(compact ? "inline-flex items-baseline" : "inline-flex min-w-16 flex-col gap-0.5 rounded px-1", "text-xs lining-nums tabular-nums", TIER_CLASS[tier], tier === "soon" && !compact && "bg-expiring-soft text-foreground", className)} > <span id={`${id}-label`} hidden> {label} </span> <span id={`${id}-digits`} data-countdown-digits data-numeric=""> {formatRemaining(remaining)} </span> {!compact && ( <span aria-hidden className="block h-1 w-full overflow-hidden rounded-full bg-muted"> <span ref={bar} className={cn("block h-full w-full origin-left", BAR_CLASS[tier])} style={staticBar ? { transform: `scaleX(${fraction})` } : undefined} /> </span> )} {announce && ( <span aria-live="polite" className="sr-only"> {message} </span> )} </span> ) }hooks/use-clock.ts import { useCallback, useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() // Called through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribe = useCallback((cb: () => void) => c.subscribe(cb), [c]) const now = useCallback(() => c.now(), [c]) return useSyncExternalStore(subscribe, now, now) }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, useInsertionEffect, useLayoutEffect, 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) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) 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 // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) 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}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></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) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } 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) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { 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) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } 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 /** Read the source for an event timestamp without changing the tick snapshot or starting a timer. */ sample?(): number } /** One interval shared by every subscriber; it runs only while someone is listening. An interval that is not a positive number of milliseconds a timer can hold, up to 2^31 − 1, ticks once a second. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { // A zero, negative, NaN, or overlong interval (a timer holds 2^31 − 1 ms and fires at once past it) would run as // fast as the timer allows and re-render every subscriber each time. const every = intervalMs > 0 && intervalMs <= 2_147_483_647 ? intervalMs : 1000 let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, sample: source, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, every) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } }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 while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. 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 a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ 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 /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => 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: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.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 (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } 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() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() 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 declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps 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 = declaredSteps } } 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 { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || 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 — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) 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) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, 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" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // 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 /** The level's field in `{field}`: a ticket's price, a two-sided quote's bid and ask. */ price: string bid: string ask: string /** The unit after a distance in ticks: a word, or a function of the count, for a plural a word can't hold. */ ticks: string | ((n: number) => string) /** The unit after a distance in basis points: a word, or a function of the count. */ bps: string | ((n: number) => 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", price: "price", bid: "bid", ask: "ask", ticks: (n) => (n === 1 ? "tick" : "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, ...defined(context.labels) } const problems: Problem[] = [] const quantity = draft.quantity // A fractional size prints exactly, or the prompt would round the trader's own number and // the limit into a lie such as "10 is above the size limit of 10". const size = (v: number) => (Number.isInteger(v) ? formatQuantity(v) : String(v).replace(/-/g, "−")) 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: size(quantity), max: size(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: size(quantity), max: size(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: size(quantity), min: size(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: size(quantity), min: size(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 = (n: number) => { const word = labels[max.unit] return typeof word === "function" ? word(n) : word } const words = { field: labels[field], distance: `${formatTicks(distance.value, { signed: false })} ${unit(distance.value)}`, max: `${formatTicks(max.value, { signed: false })} ${unit(max.value)}` } 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 messages = new Map<string, string>() for (const p of problems) if (!messages.has(p.field)) messages.set(p.field, p.message) return Object.fromEntries(messages) } -
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. Render the ticket on the client: the countdown reads a shared clock that advances only while something in a browser subscribes, so server-rendered digits go stale and mismatch on hydration. 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 crossed pair to see the crossed-quote check — a bid above the offer normally, below it where a higher quote means a lower price. A declared quoteInverted decides for any basis; the defaults are yield and discount inverted, price and spread not, since CDS quotes bid below offer while cash credit quotes the other way. 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, snapped to the quote grid, so the level sent is the one the button and the field show. 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 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, limits, and use-hotkeys at a given registry version. Editing the installed file is the intended way to change its layout. Everything in rfq-ticket.tsx is then yours — the markup, the draft state, the handlers and keys, and the exported quotedSides, formatSize, checkQuote, describeQuote, and quoteDistance — while the shared files it installs beside it, quote-field, countdown, format, limits, use-flash, the clock files, and the hotkey files, keep taking updates from any item that installs them. Take later fixes to rfq-ticket.tsx itself by hand: read an update with --diff before taking it.
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 levels for the sides the inquiry asks for, snapped to the quote grid; read only on mount. A level for a side it doesn't ask for is left out of the draft. |
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, blocks action execution, and stops every ticket shortcut. |
hotkeys |
boolean |
true |
Declares RFQ_TICKET_BINDINGS as registry defaults, the size keys for the quick sizes passed. |
limits |
Limits |
None | Checks quoted sides before sending. |
limitsLabels |
Partial<LimitsLabels> |
DEFAULT_LIMITS_LABELS with ask: "offer" |
The words of the limits' sentences. |
quickSizes |
readonly number[] |
None | Alternative quote quantities in raw notional or contracts: whole numbers above zero, each once, and any other is left out. |
labels |
Partial<RfqTicketLabels> |
DEFAULT_RFQ_TICKET_LABELS |
Overrides the ticket's own wording. A word given as undefined keeps its default. |
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 with role="status", so each change is announced, 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". The tone colors the value, sets data-direction on it, and adds labels.toneUp or labels.toneDown for a screen reader. |
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, said from a status region that is in the page from the first render. |
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. A typed decimal snaps to the printable grid when parsed, so the quoted level is the grid price the field's text formats to. 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 neither a market side nor a suggestion nor market.mid, stepping does nothing, and maxDistance skips a field that has no market to measure from.
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 as it stands, a resize under the same id included, until a quick size other than the inquiry's is taken; the ticket includes it in emitted drafts |
onDraftChange reports changes to this draft after render, a resize that changes its quantity included. It does not report the initial draft, unchanged values, or other inquiry updates. Changing defaultDraft later has no effect; remount to reset it. A side the inquiry doesn't ask for is always null.
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?) |
Millions of notional (5_000_000 → 5mm), with millions as the unit, or a contract count. |
quoteDistance(level, market, convention, bp?) |
{ value, text }, or null for a missing or nonfinite input, with bp as a basis-point distance's unit. Both levels accept number | null | undefined. |
checkQuote(draft, inquiry, labels?) |
RfqQuoteProblems: optional bid and ask messages for a required level that is null or non-finite, or, on a two-way, a crossed pair read through the quote direction. |
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 or quantity, and never reads a side the inquiry doesn't ask for; on a two-way it rejects crossed levels through the instrument's quote direction whenever both are finite — bid above offer normally, bid below offer where quoteInvertedOf reads the instrument as inverted — and a required side that is null or non-finite gets its needed message instead.
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 |
Draws the action in the destructive color on the outline button, which holds 4.5:1 at rest and turns to the foreground under the pointer. |
primary |
boolean |
Automatic | Preferred by rfq.send among quote-sending actions that are not destructive; rfq.send never runs a destructive action. Uses primary styling unless destructive. |
Only matching actions render, in your actions order. No matches produces the nothing-allowed message. The primary action — for styling — is the first allowed action marked primary, otherwise the first that needs a quote, otherwise the first allowed action. The send key chooses separately: the first allowed quote-sending action that is not destructive, preferring one marked primary, so neither a needsQuote: false action nor a destructive one ever runs on it, however it is marked.
A press counts once: the second click of a double-click, within the system's double-click time and distance, and the clicks a held Enter repeats on a focused button, run nothing. Two separate presses run the action twice unless run takes it out of allowedActions at once; do that, or have your server refuse the repeat.
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, and when the control under focus leaves with the change — a sent action's button unmounts — focus moves to the ticket itself, so the shortcuts stay live; this park is the one exception to the parent owning focus, it fires only while focus was in this ticket, a deliberate click elsewhere is remembered as leaving, and a window switch is not — a control withdrawn while the dealer is away still parks on return. A non-finite market, suggested, or default level — a feed still warming up — counts as absent everywhere: fields, steps, the suggestion button, and the market row's readout. 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. Under reduced motion the hook only marks data-direction, and the box styles nothing by it, so nothing visible happens.
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 levels and any quick size taken. 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 as registry defaults, the size keys only for the quick sizes passed:
| Binding | Default key | Action |
|---|---|---|
rfq.send |
mod+enter |
Runs the first allowed quote-sending action that is not destructive, preferring the primary; its key caps render on that action's button, and it does nothing when no such action is allowed. |
rfq.tick-up, rfq.tick-down |
mod+up, mod+down |
Steps the side the key came from — its field or step buttons — else the first field. |
rfq.suggested |
mod+shift+a |
Copies suggested levels. |
rfq.size-1 … rfq.size-N |
mod+1 … mod+N |
Selects an entry from quickSizes, in supplied order, after any that isn't a whole number above zero and any repeat are left out; declared through N, the count of sizes left, at most nine. |
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. The defaults stand while any ticket with hotkeys enabled remains: your registration of an id shadows the ticket's default whenever it comes, unregistering yours brings the default back, and unregister("rfq.send") does nothing while a ticket holds the default — so a binding you declare stays yours through the per-inquiry remounts. Handlers run anywhere inside the ticket, and dialog boundaries keep outside handlers from running.
hotkeys={false} skips declarations but still binds handlers for these ids, declared before or after. Without a provider, there are no shortcuts or key hints. Plain Enter sends nothing; the ticket has no form.
A disabled ticket runs no shortcuts at all, though its keys are still consumed. While nobody declares an absent size's id, its keys pass through untouched; once the id is declared in the editing scope — by you, or by another ticket with more sizes on the shared registry — the registry consumes the key wherever the fence reaches, and a ticket without that size does nothing with it; keep ticket ids in editing, since a declaration in another scope dispatches unfenced. Draft shortcuts run only while the fields are live — an allowed action needs a quote — exactly as the controls do; the send key runs only an action that sends the quote; and sending checks everything again as the click lands. remap(id, "") turns a key off as a user override the hotkey editor shows and Reset undoes. 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, in the same unit the field's distance readout uses; on a one-sided market the limit measures from its fallback while the field shows no distance. sides checks the dealer's side: a bid is a buy and an offer is a sell. The sentences take their words from limitsLabels, over DEFAULT_LIMITS_LABELS with the ask called the offer, and say one tick in the singular.
A block shows under its field, or below the actions for any other field, a side the inquiry doesn't ask for included, so whatever holds Quote is on screen, and holds actions that need a quote: each looks disabled, carries aria-disabled, and stays focusable, and a press on it is refused. A confirm applies to any action that needs a quote: its button becomes Quote anyway? (using that action's label) and the reason appears below. A reason added while the question stands makes the next press ask again rather than send, and a question whose action the venue takes away is withdrawn, so if the action comes back, the next press asks again; the next fresh press of the same action sends if checks pass; a double-click's second click or a held Enter's repeat does not count. Editing a level, choosing a size, or the venue resizing the inquiry clears the confirmation. Both checks run live and again on execution; needsQuote: false actions bypass them.
A screen reader hears the limits when a press meets them, from one polite announcer: the blocks the press was refused for, on a level or any other field, or the question it asked, in the words they have as it lands. A held action stays in reach, so a press from a pointer, a key, touch, or assistive technology counts. Nothing is said as typing or the market changes the limits. A level's block shows under its field without an alert or a description, since the market rewords it; a press's own problem with a level, such as a crossed quote, is the field's alert and its description, a later press that meets it again is said by the announcer, since the alert's words haven't changed, and a new level on either side clears a crossed quote's message. What was said goes once the draft changes or any of it stops standing, and a block that comes back waits for the next press.
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. A size that isn't a whole number above zero is left out, and a repeated one shows once; shortcuts index what remains, in your order, the inquiry's own size included where you listed it. Sizes use the convention's unit, and the selected button has aria-pressed="true".
Choosing a size changes the draft's quantity, and choosing the inquiry's own size follows the inquiry again through a resize. 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, the unparsable-level message (invalidLevel, shown by the quote fields), confirmation text (anyway, with an {action} placeholder), the step buttons' names (stepUp and stepDown, with a {label} placeholder), the notional unit (millions), the basis-point unit (bp), and the words a screen reader hears after a toned context value (toneUp and toneDown). Action labels and server text come from their own inputs, limit messages from limitsLabels, hotkey descriptions from the bindings you register, and the send button's key caps from the hotkeys library.
The group is named Inquiry Q-1 by default, using labels.ticket and the inquiry id, and the timer the same with the time left, such as Inquiry Q-1 0:30. Fields have visible labels and use aria-invalid when an error is shown. The market and quoted levels are a table, so a screen reader reads each level with its row and side. Focus resting on the ticket itself draws a ring in the foreground at 60%, which holds 3:1.
Tokens
The install includes up, down, and flat tokens and their soft variants, stale for the confirm line, and expiring with expiring-soft for the countdown, along with the shared font tokens and the hyperlegible remap. 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 v2.0.0.