`, rather than cloning `role` onto the child's own
* props. Wrapping instead of cloning is deliberate: `React.cloneElement` on a child whose type is
* `React.Fragment` silently drops an added `role` prop (Fragments only accept `key`/`children`),
* so cloning left a Fragment child with NO listitem semantics at all and no warning either. A
* wrapper element needs no such prop-cloning heuristic, and it works identically no matter what
* the child is — an element, a Fragment, a string, a number — because it never inspects or
* touches the child's own props. Two behaviours fall out on top of "wrap by default":
* - Fragments are flattened first (recursively), so `<>
>` as one CardGrid
* child becomes TWO listitems, not one listitem wrapping a Fragment.
* - A child that already sets its own `role` opts out of being a listitem at all (left
* unwrapped, exactly where React put it) — e.g. an inline empty-state message that should
* span every column rather than sit inside the list structure. This is the one case where
* "leave it alone" (the old cloning behaviour) is still correct, since wrapping it would
* just move the opt-out problem one level down.
* The wrapper needs a stylesheet counterpart (`.fd-card-grid > [role="listitem"]`, components.css)
* so the wrapper — now the actual grid item — passes the row's stretched height through to its
* one real child, the same way `.fd-card`'s own internal flex column already passes extra height
* down to `.fd-card-foot`.
*/
interface CardGridProps {
children?: React.ReactNode;
/** The width, in px, a card may shrink to before a column drops. Default 280 is tuned so a
* 375px-wide content column (a phone) is always exactly one column and a ~1280px desktop
* content column fits four — see the file doc comment. A value wider than the container
* collapses to one full-width column rather than overflowing it. */
minCardWidth?: number;
/** One of the spacing scale's named tiers (styles/tokens.css's `--s-*`), same vocabulary as
* `fd-gap-*` (SMS-7). Applies to both the row and column gap. */
gap?: "sm" | "md" | "lg" | "xl";
className?: string;
style?: React.CSSProperties;
}
declare function CardGrid({ children, minCardWidth, gap, className, style }: CardGridProps): React.JSX.Element;
/**
* The async contract for one region of a screen. Four states, one component:
* loading (skeleton), refreshing (previous data stays visible, dimmed and inert),
* ready, empty/error.
*/
interface LoadingRegionProps {
state?: "loading" | "refreshing" | "ready" | "empty" | "error";
/** What to show before first data. Mirror the ready layout. */
skeleton?: React.ReactNode;
children?: React.ReactNode;
/** Announced to screen readers while busy. */
label?: string;
error?: string;
onRetry?: () => void;
className?: string;
}
declare function LoadingRegion({ state, skeleton, children, label, error, onRetry, className, ...rest }: LoadingRegionProps): React.JSX.Element;
type JobCardStatus = "active" | "done" | "failed";
interface JobAction {
label: string;
onClick: () => void;
/** Phosphor icon name, e.g. "download-simple" */
icon?: string;
}
/** One background job's whole state, exactly as the server would persist and return it —
* JobTray never keeps its own copy, so the same `jobs` array renders identically the
* instant after a page refresh as it did before. */
interface Job {
id: string;
title: React.ReactNode;
note?: React.ReactNode;
status: JobCardStatus;
/** 0-100. Omit while `status` is "active" for an indeterminate bar. Ignored otherwise. */
progress?: number;
/** e.g. "1,200 of 5,000 imported" — shown under the bar while active. */
progressNote?: React.ReactNode;
/** The done state's call to action, e.g. { label: "Download", onClick, icon: "download-simple" }. */
action?: JobAction;
/** Shown next to the action once done, e.g. "Kept for 24 hours". */
doneNote?: React.ReactNode;
/** Required content once `status` is "failed". */
failureReason?: React.ReactNode;
}
interface JobTrayProps {
jobs: Job[];
/** Called with the job's id when its dismiss control is used. */
onDismiss: (id: string) => void;
className?: string;
}
/**
* A stack of background-job cards — exports, syncs, anything that outlives the screen that
* started it. Entirely props-driven: no client-only timers, so a job the server persisted
* renders in the right state (including mid-progress) the moment the page loads, refresh or
* not. Sits in the shared bottom-right corner stack, so a toast stacks above it, never over it.
*/
declare function JobTray({ jobs, onDismiss, className }: JobTrayProps): React.JSX.Element | null;
type PhaseStatus = "pending" | "active" | "done" | "failed";
interface Phase {
id: string;
label: React.ReactNode;
status: PhaseStatus;
/** e.g. "importing 1,200 of 5,000" */
count?: React.ReactNode;
}
interface PhasedProgressProps {
/** Ordered start to finish; render order is the order given. */
phases: Phase[];
/** Shown once the run has reached a terminal state (a phase failed, or the last is done). */
summary?: React.ReactNode;
className?: string;
}
/**
* A named sequence of steps for one long server-side job (reading a source, importing rows,
* loading a warehouse, done or failed) — entirely props-driven, so it renders correctly from
* persisted state after a refresh, mid-run or finished. Reuse `JobTray` beside it when the
* job also needs a dismissible card in a corner tray; use this on its own for a screen that
* already owns the layout, such as a Sync button's inline detail.
*/
declare function PhasedProgress({ phases, summary, className }: PhasedProgressProps): React.JSX.Element;
/** Determinate or indeterminate bar for a known long job — uploads, exports, plan builds. */
interface ProgressBarProps {
/** 0-100. Omit for indeterminate. */
value?: number;
/** Shown above the bar, and the bar's accessible name. */
label?: string;
/** The bar's accessible name when its surroundings already show what it measures, so no label is shown. */
"aria-label"?: string;
showValue?: boolean;
className?: string;
}
declare function ProgressBar({ value, label, "aria-label": ariaLabel, showValue, className, ...rest }: ProgressBarProps): React.JSX.Element;
/**
* A skeleton exactly as large as the content it stands in for. `children` is that content's own
* layout (or its fullest case), really laid out so the box takes its size at every width, yet
* never seen, focused, clicked or read out: it is `visibility: hidden`, `inert` and
* `aria-hidden`. A skeleton block covers it edge to edge, so the region holds its final space
* from the first paint and nothing below it moves when the real content replaces it.
*
* Like Skeleton it announces nothing: pair it with a LoadingRegion (or another role="status"
* wrapper) so the busy state still reaches a screen reader. The sheen, reduced-motion pulse and
* both themes are the skeleton block's own.
*/
interface ReservedSkeletonProps extends Omit
, "children"> {
/** The content to reserve space for. Rendered, but never shown or reachable. */
children: React.ReactNode;
/** The skeleton block's shape, as on Skeleton. */
shape?: "block" | "card";
/** Override the shape's default border radius. */
radius?: string;
}
declare function ReservedSkeleton({ children, shape, radius, className, ...rest }: ReservedSkeletonProps): React.JSX.Element;
/**
* The shape data will occupy, shown before the first payload lands.
* Skeletons mirror the real layout so nothing shifts on arrival.
*/
interface SkeletonProps {
w?: number | string;
h?: number | string;
shape?: "block" | "text" | "circle" | "card";
radius?: string;
style?: React.CSSProperties;
className?: string;
}
declare function Skeleton({ w, h, shape, radius, style, className, ...rest }: SkeletonProps): React.JSX.Element;
interface SkeletonTextProps {
lines?: number;
/** Per-line widths, cycled. Ragged widths read as text; equal widths read as a bug. */
width?: string[];
size?: number;
gap?: number;
className?: string;
}
declare function SkeletonText({ lines, width, size, gap, className }: SkeletonTextProps): React.JSX.Element;
/** A small indeterminate indicator. For inline waits only — never over a whole page. */
interface SpinnerProps {
size?: "sm" | "md" | "lg";
label?: string;
className?: string;
style?: React.CSSProperties;
}
declare function Spinner({ size, label, className, style, ...rest }: SpinnerProps): React.JSX.Element;
/**
* The app's 264px left rail. Grouped items under overline headers; the active item
* takes a soft blue fill and deep blue label. Collapses off-canvas below 1024px.
*/
interface SidebarNavItem {
value: string;
label: string;
/** Phosphor icon name */
icon: string;
count?: number;
/** Count still loading — shows a small skeleton instead of a number. */
loading?: boolean;
/** Member lacks the permission — shows a lock marker. Still navigable; the screen explains. */
locked?: boolean;
/** Designed but the backend is not wired — shows a construction marker. Still navigable;
* the screen renders inert behind a coming-soon banner. */
preview?: boolean;
}
interface SidebarNavProps {
brand?: React.ReactNode;
groups: Array<{
label?: string;
items: SidebarNavItem[];
}>;
current?: string;
onNavigate?: (value: string) => void;
footer?: React.ReactNode;
/** Mobile: slides the off-canvas rail in. */
open?: boolean;
className?: string;
}
declare const SidebarNav: React.ForwardRefExoticComponent>;
/** 60px app header: breadcrumb left, actions right, and a 2px route loader along its top edge. */
interface TopbarProps {
/** A non-last crumb with `onClick` calls it (via `preventDefault`) instead of following
* `href` — for a crumb that needs to run a side effect (e.g. clearing some selection)
* before/instead of navigating. `href` still works on its own for a plain link crumb;
* the two aren't mutually exclusive, but `onClick` wins when both are present. */
crumbs?: Array<{
label: string;
href?: string;
onClick?: () => void;
}>;
/** Rendered last, after any `overflowActions` — the one control every screen needs to
* keep directly reachable (typically the account menu). Never collapses, at any width. */
actions?: React.ReactNode;
/** Secondary controls (an assistant launcher, a version badge, appearance/dev-tools
* toggles, undo — whatever a screen needs beside `actions`). Rendered inline, in the
* same spot `actions` used to hold everything, on a viewport wider than
* `overflowBreakpoint`. At or under it, they collapse into a single "More actions"
* menu instead of pushing the rest of the bar past the screen edge. Decided once
* here, so every Topbar consumer gets it without re-solving it per screen. */
overflowActions?: React.ReactNode;
/** Viewport width (px) at or under which `overflowActions` collapses into the "More
* actions" menu. Default 640 — comfortably covers 320/375/430 phone widths without
* touching any tablet or desktop layout above it. */
overflowBreakpoint?: number;
/** Shows the indeterminate route loader on the top edge. */
loading?: boolean;
/** Mobile menu handler — renders the hamburger. */
onMenu?: () => void;
className?: string;
}
declare function Topbar({ crumbs, actions, overflowActions, overflowBreakpoint, loading, onMenu, className, ...rest }: TopbarProps): React.JSX.Element;
/**
* MP-221: the Topbar + SidebarNav + useResponsiveRail composition every flytedesk app
* shell hand-assembled — the full-height flex column, the rail/content flex row, the
* scrim, the overlay-close-on-navigate wiring and the centered, max-width content
* column — lived three times over (media-planner's PlannerShell, sms-app's Shell, and
* this package's own SidebarNav docs demo, which told every new app to copy it). The
* design of this API reads BOTH real compositions (PlannerShell.tsx, Shell.tsx): a
* plain "app passes nav items + identity + actions" shape covers everything the two
* shared, and each of PlannerShell's app-specific extras (the command palette,
* TestModeBar/RolePreviewBar, the assistant panel, Dev tools) stays out of it, reached
* instead through `banner` and `sidePanel`, or rendered around AppShell entirely.
*
* `useResponsiveRail` itself (overlay-vs-docked detection, Escape, focus trap/return,
* body scroll lock) is still exported on its own — AppShell only owns wiring it to a real
* SidebarNav/Topbar/scrim so no app has to re-wire it by hand, including providing the
* rail's layer around SidebarNav so a menu opened in the rail is inside its focus scope.
*/
/** DS-26: the one side panel an AppShell manages (an agent chat, a detail pane). Wide screens dock it under the
* Topbar beside the content; at and below the rail's overlay breakpoint it is a bare `Drawer` sheet, so an app
* never branches on the viewport for it. The app owns whether it is open and, if it wants to remember it, the width. */
interface AppShellSidePanel {
open: boolean;
/** Asked with `false` when a person closes the sheet (Escape, scrim). Open it by setting `open`. */
onOpenChange: (open: boolean) => void;
/** Names the sheet dialog. The docked panel is a plain box: its children (AgentChatPanel is an `aside`) are its landmark. */
label: string;
children: React.ReactNode;
/** The docked width in px when the app owns it; the panel then moves only as the app passes back what
* `onWidthChange` reported. Omit for a width the panel keeps itself. */
width?: number;
/** The docked width before a person resizes it. Default 420. */
defaultWidth?: number;
/** Resize floor and ceiling in px. Default 340 and 760. */
minWidth?: number;
maxWidth?: number;
/** Called with each new width a person's resize produces, for an app to save. */
onWidthChange?: (width: number) => void;
}
interface AppShellProps {
/** Rendered in SidebarNav's fixed-height brand row (e.g. FlytedeskBrand/FlytedeskMark,
* optionally wrapped in a "go home" button — that wrapping is app behaviour, not
* something AppShell can supply generically). */
brand?: React.ReactNode;
/** SidebarNav's groups. */
navGroups: Array<{
label?: string;
items: SidebarNavItem[];
}>;
/** The active item's value, matched against each item's own `value`. */
currentNav?: string;
/** Called with an item's value when it's clicked. AppShell closes the rail itself
* first when it's an overlay — an app never has to remember that half of the wiring. */
onNavigate: (value: string) => void;
/** SidebarNav's footer slot (e.g. media-planner's "Jump to… ⌘K" trigger). */
navFooter?: React.ReactNode;
/** The rail's open state on first render while docked — the seam for an app's own
* persisted desktop preference (e.g. a `usePref`-backed value). Ignored while the
* initial viewport is already in overlay range, which always starts closed. */
initialNavOpen?: boolean;
/** Called with the new open state whenever it changes while docked — an app's hook
* into persisting `initialNavOpen`. Never called for a transient overlay open/close. */
onNavOpenChange?: (open: boolean) => void;
/** Max-width (px) at which the rail becomes a scrimmed overlay instead of a docked
* column. Must match the design system's own `.fd-side` breakpoint (default `OVERLAY_BREAKPOINT`)
* unless an app's own CSS overrides that breakpoint too. */
railBreakpoint?: number;
/** Topbar's breadcrumbs. */
crumbs?: TopbarProps["crumbs"];
/** Shows Topbar's route-loading indicator along its top edge. */
loading?: boolean;
/** Topbar's never-collapsing slot — the one control every screen needs to keep
* directly reachable, typically the AccountMenu. */
actions: React.ReactNode;
/** Topbar's slot that collapses into a "More actions" menu under `overflowBreakpoint`
* (an assistant launcher, AppearanceMenu, a version badge, Dev tools, undo, …). */
overflowActions?: React.ReactNode;
overflowBreakpoint?: number;
/** A full-width band rendered above the rail/content row, outside the scrollable
* content column — e.g. a test-mode or role-preview banner. Omit for none. */
banner?: React.ReactNode;
/** The managed side panel (see `AppShellSidePanel`). Omit for none. */
sidePanel?: AppShellSidePanel;
/** Max width of the centered content column: a px number, or `"none"` for a
* full-width app whose pages fill the space beside the sidebar (SMS-50). Default
* 1440 — the value most flytedesk app shells use. */
contentMaxWidth?: number | "none";
/** The shell's own height. Default `"100vh"`, correct for the real, top-level use
* every app mounts this at. A demo or preview embedding AppShell inside a bounded
* container (this package's own docs site) passes e.g. `"100%"` instead. */
height?: string | number;
/** Extra class name on the content column (e.g. media-planner's container-query
* class for its own responsive grids). */
contentClassName?: string;
className?: string;
/** The routed screen content, rendered inside the padded, centered content column. */
children: React.ReactNode;
}
declare function AppShell({ brand, navGroups, currentNav, onNavigate, navFooter, initialNavOpen, onNavOpenChange, railBreakpoint, crumbs, loading, actions, overflowActions, overflowBreakpoint, banner, sidePanel, contentMaxWidth, height, contentClassName, className, children, }: AppShellProps): React.JSX.Element;
interface FlytedeskMarkProps {
size?: "sm" | "md" | "lg";
className?: string;
}
/** Just the flytedesk logo, no label — for contexts too small for a stacked brand
* (a collapsed rail, a favicon-sized corner mark). */
declare function FlytedeskMark({ size, className, ...rest }: FlytedeskMarkProps): React.JSX.Element;
interface FlytedeskBrandProps {
/** The consuming app's name, shown under the logo — e.g. "Media Planner", "Admin". */
appName: string;
size?: "sm" | "md" | "lg";
className?: string;
}
/** The flytedesk logo stacked over the app's name — meant to drop straight into
* SidebarNav's `brand` slot in every flytedesk app. */
declare function FlytedeskBrand({ appName, size, className, ...rest }: FlytedeskBrandProps): React.JSX.Element;
/**
* MP-221: the Flytedesk favicon — the exact `.ico` media-planner and sms-app both
* shipped as a byte-identical copy in `apps/web/public/favicon.ico` (SMS-5 gate
* review) — now ships as a real file in this package, at `assets/favicon.ico`
* (`"./assets/*"` in package.json's `exports`). A Vite app resolves it with the same
* `?url` suffix it already uses for any other imported asset, then installs it with
* this helper instead of hand-writing the ` ` DOM code per app:
*
* import favicon from "@flytedan/flytebot-design-system/assets/favicon.ico?url";
* import { installFlytedeskFavicon } from "@flytedan/flytebot-design-system";
*
* installFlytedeskFavicon(favicon); // once, e.g. at the top of main.tsx
*
* Takes the resolved URL rather than resolving it itself: asset URL resolution is the
* consuming app's own bundler's job (Vite rewrites the import at build time to a
* hashed, content-addressed path), not something this package can do on an app's
* behalf without bundling a copy of every app's Vite config.
*/
declare function installFlytedeskFavicon(url: string): void;
interface TabItem {
value: string;
label: string;
count?: number;
/** `attention` colours the count as work that is waiting. Without it the count is neutral. */
tone?: "attention";
}
/**
* Places within one screen. Underline only — no pill tabs in the product.
* Tabs that do not fit scroll sideways, fading the edge that has more, with the active tab kept in view.
*/
interface TabsProps {
tabs: Array;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
declare function Tabs({ tabs, value, onChange, className, ...rest }: TabsProps): React.JSX.Element;
/** A person or organisation mark. Initials on a soft brand tint when no image exists. */
interface AvatarProps {
name?: string;
src?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
declare function Avatar({ name, src, size, className, ...rest }: AvatarProps): React.JSX.Element;
interface AvatarStackProps {
people: Array;
size?: "sm" | "md" | "lg";
max?: number;
className?: string;
}
declare function AvatarStack({ people, size, max, className }: AvatarStackProps): React.JSX.Element;
type BannerTone = "info" | "ok" | "warn" | "danger";
interface BannerProps {
tone?: BannerTone;
title: React.ReactNode;
detail?: React.ReactNode;
actions?: React.ReactNode;
/** A Banner reporting a failure (danger) is a live-region announcement that interrupts
* and announces immediately (`role="alert"`); every other tone — warn, info, ok —
* announces politely once idle (`role="status"`), since a static warning or advisory
* banner isn't urgent enough to talk over whatever the user is doing. Defaults by
* tone; a caller can still override either way. */
role?: "alert" | "status";
}
declare function Banner({ tone, title, detail, actions, role }: BannerProps): React.JSX.Element;
/**
* A pill of state. Badges never carry actions — if it is clickable it is a Tag.
*
* Extends HTMLAttributes so a consumer can pass `title`, `aria-*`, `style` or any other
* plain span attribute straight through, the same rest-prop passthrough every other kit
* component follows — a caller that needs one of those never has reason to hand-roll the
* markup instead of using the component.
*/
interface BadgeProps extends React.HTMLAttributes {
children?: React.ReactNode;
/** live = the site's blue gradient "Active". approved = its mint pill. count = the blue number chip. */
tone?: "neutral" | "info" | "success" | "warning" | "danger" | "live" | "approved" | "count";
dot?: boolean;
/** Phosphor icon name */
icon?: string;
className?: string;
}
declare function Badge({ children, tone, dot, icon, className, ...rest }: BadgeProps): React.JSX.Element;
/** Says what will appear here, and offers exactly one button. */
interface EmptyStateProps {
/** Phosphor icon name */
icon?: string;
title: React.ReactNode;
children?: React.ReactNode;
action?: React.ReactNode;
className?: string;
}
declare function EmptyState({ icon, title, children, action, className, ...rest }: EmptyStateProps): React.JSX.Element;
/**
* A callout with a fixed structure: statement, then cost, then two actions.
* A flag without a number and a choice is not a flag — use plain body copy.
*/
interface FlagProps {
/** What is true. "Ohio State is Weak." */
statement: React.ReactNode;
/** What it costs to fix. "+$4,200 reaches Adequate." Rendered bold. */
cost?: React.ReactNode;
/** Two Buttons — the fix and the dismissal. */
actions?: React.ReactNode;
tone?: "info" | "success" | "warning" | "danger";
className?: string;
}
declare function Flag$1({ statement, cost, actions, tone, className, ...rest }: FlagProps): React.JSX.Element;
/** A key hint that binds NOTHING — register the real shortcut separately. */
interface KeyHintProps {
keys: string;
size?: "sm" | "md";
tone?: "quiet" | "solid";
className?: string;
}
/**
* A key hint. Renders a label and binds NOTHING — register the real shortcut
* separately, or the cap and the binding drift apart.
*/
declare function KeyHint({ keys, size, tone, className, ...rest }: KeyHintProps): React.JSX.Element;
/** The platform modifier as text, for prose: "⌘" or "Ctrl". */
declare function modifierLabel(): string;
/** True when the event carries the platform's primary modifier. */
declare function hasModifier(e: KeyboardEvent | React.KeyboardEvent): boolean;
/** A small status chip for a screen header that explains itself in a titled panel. */
interface StatusIndicatorProps {
/** The painted text, e.g. "5 rules" or "Do-not-call". */
label: string;
/** What the status is right now, e.g. "screened". With it the accessible name is
* ": "; without it the name is the label alone. */
status?: string;
/** Phosphor icon name, leading. */
icon?: string;
/** Colours the chip. Never a pressed state. */
tone?: "neutral" | "success" | "warning" | "danger";
/** Shows a spinner in the icon slot and marks the chip busy. The chip keeps its size. */
loading?: boolean;
/** The panel's title (Popover `title`: the title plus a Close button). */
panelTitle: string;
/** The panel body: what the status means. */
children?: React.ReactNode;
className?: string;
}
/**
* StatusIndicator (DS-51): a status chip that is not a toggle. It opens a Popover on hover,
* keyboard focus or tap (useHoverPopover, DS-50) and exposes aria-haspopup="dialog" and
* aria-expanded; it never sets aria-pressed.
*/
declare function StatusIndicator({ label, status, icon, tone, loading, panelTitle, children, className, }: StatusIndicatorProps): React.JSX.Element;
/** A clickable chip — filters, selected campuses, applied channels. */
interface TagProps {
children?: React.ReactNode;
icon?: string;
selected?: boolean;
/** Colors the chip once selected — e.g. a favoured ("success") or excluded ("danger") toggle. No effect while unselected. */
tone?: "neutral" | "info" | "success" | "warning" | "danger";
onRemove?: () => void;
onClick?: () => void;
/** MP-282: a native tooltip for the chip's full text — the same full text that is
* already the button's accessible name (its text content), so this only helps a
* sighted mouse user whose chip a caller (FilterBar, scoped to `.fd-filterbar .fd-tag`
* in components.css) has visually truncated with an ellipsis. Harmless, and invisible,
* on a chip nobody truncates. */
title?: string;
/** The chip's accessible name, when its painted text is not a faithful name for it. FilterBar
* sets it to the chip's whole text: the painted text is split into a shrinkable head and a
* fixed tail (MiddleEllipsis), and a browser computing a name from content puts a space between
* two block-level flex items, reading "Pol itical" — an explicit name is the whole value. */
"aria-label"?: string;
className?: string;
}
declare function Tag({ children, icon, selected, tone, onRemove, onClick, className, ...rest }: TagProps): React.JSX.Element;
/** Confirmation of something that already happened. Bottom-right, 5s, undo where reversible. */
interface ToastProps {
title: React.ReactNode;
children?: React.ReactNode;
tone?: "success" | "error" | "info" | "loading";
onUndo?: () => void;
onDismiss?: () => void;
className?: string;
}
declare function Toast({ title, children, tone, onUndo, onDismiss, className, ...rest }: ToastProps): React.JSX.Element;
/** How long a toast stays when `show` is not told otherwise — `Toast`'s own convention. */
declare const DEFAULT_TOAST_DURATION_MS = 5000;
interface ToastOptions {
/** The toast's icon and accent. Default "success": a toast confirms what already happened. */
tone?: ToastProps["tone"];
/** Milliseconds before it dismisses itself; 0 keeps it until its close button is clicked.
* Default `DEFAULT_TOAST_DURATION_MS`. */
duration?: number;
}
interface ToastApi {
/** Shows a toast in the bottom-right corner stack, above any job tray. Stable across renders. */
show: (title: React.ReactNode, options?: ToastOptions) => void;
}
/**
* Hosts the app's toasts: a stack in the shared bottom-right corner (`CornerStackPortal`), oldest on top, that any descendant adds
* to through `useToast().show`. `Toast` is only the presentation; this owns the queue and the
* auto-dismiss timers. Mount one near the root, inside the theme.
*/
declare function ToastProvider({ children }: {
children?: React.ReactNode;
}): React.JSX.Element;
/** The app's toast host (see `ToastProvider`). Throws outside one. */
declare function useToast(): ToastApi;
/** The handlers that make an element a hover / focus / tap target. Spread them on any element,
* an SVG `` included: they read the element from the event, so no wrapper is needed. */
interface TooltipBinding {
onPointerEnter: (e: React.PointerEvent) => void;
onPointerLeave: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onFocus: (e: React.FocusEvent) => void;
onBlur: (e: React.FocusEvent) => void;
}
type TooltipPlacement = "top" | "bottom";
interface UseTooltipOptions {
placement?: TooltipPlacement;
/** Extra class names on the bubble. */
className?: string;
}
interface TooltipApi {
/** Props for one target. `id` names it for as long as it exists: one per target, stable across renders.
* Call it in the same render as `useTooltip`, for every target: the bubble says what the labels bound in
* THAT render say, so a target bound later (in an effect, a callback or a child) shows an empty tip. */
bind: (id: string, label: React.ReactNode) => TooltipBinding;
/** The bubble, or null while nothing is open. Render it once, beside the targets, never inside an ``. */
bubble: React.ReactNode;
/** The `id` of the target the tip is on, or null. */
activeId: string | null;
}
/**
* useTooltip: the kit's tooltip behaviour for any element, an SVG shape included.
*
* `Tooltip` wraps its trigger in a ``, which cannot sit between an `` and a
* ``. This hook is the same behaviour without the wrapper: spread `bind(id, label)` on
* each target and render `bubble` once beside them. One tip is open at a time and moves from
* target to target, so fifty states share one bubble rather than mounting fifty.
*
* Three ways in, because a tooltip reachable only by a mouse hides its text from everyone
* else: hover (mouse only), keyboard focus (`:focus-visible`, so a mouse click that happens
* to focus a target does not pin a tip open) and tap (touch or pen, which have no hover; a
* second tap closes it). Escape and a press anywhere else dismiss it, and the pointer can
* move from the target onto the tip without it closing.
*
* It does not describe its target (`aria-describedby`): `Tooltip` adds that for its one
* trigger, while a shape's label is its own accessible name, so the caller names the target
* and the tip only repeats it.
*/
declare function useTooltip({ placement, className }?: UseTooltipOptions): TooltipApi;
/** A short clarification on hover, focus or tap. Never the only place information lives. */
interface TooltipProps {
label: React.ReactNode;
placement?: TooltipPlacement;
children?: React.ReactNode;
className?: string;
}
/**
* Tooltip — the kit's one tooltip, for any trigger that can sit inside a ``.
*
* Three ways in, because a tooltip reachable only by a mouse hides its text from
* everyone else: hover (mouse only), keyboard focus (`:focus-visible`, so a mouse click
* that happens to focus a button does not pin a tip open) and tap (touch or pen — there is
* no hover on a touchscreen, so a tap toggles it). Escape and a press anywhere else
* dismiss it; the pointer can move from the trigger onto the tip without it closing.
*
* The text is the trigger's accessible DESCRIPTION, not just a visual: a visually hidden
* copy is always in the document and the trigger points at it with aria-describedby, so a
* screen reader announces it on focus whether or not the bubble is showing at that
* instant. The trigger is the single child element when there is one (it receives the
* attribute); otherwise the wrapper does.
*
* The behaviour itself is `useTooltip`, which also anchors a tip to an element that cannot
* be wrapped (an SVG shape). The bubble is portaled to document.body and positioned with the
* same anchored-layer geometry as Popover (flip, clamp, follow a moving anchor), because a
* tooltip drawn inside its trigger's box is clipped by the first `overflow: hidden` ancestor —
* which in a fixed-width table cell or a list row is all of them.
*/
declare function Tooltip({ label, placement, children, className }: TooltipProps): React.JSX.Element;
interface AnimNumProps {
value: number;
format?: (value: number) => React.ReactNode;
duration?: number;
}
declare function AnimNum({ value, format, duration }: AnimNumProps): React.JSX.Element;
type ChartState = "loading" | "refreshing" | "ready" | "empty" | "error";
interface TrendSeries {
id: string;
label: string;
/** One value per shared category, in the same order as `categories`. */
values: number[];
/** Defaults to the categorical ramp in order. */
color?: string;
}
interface TrendChartProps {
/** The shared x-axis, e.g. dates or week labels. */
categories: string[];
series: TrendSeries[];
/** Accessible name for the chart as a whole, e.g. "Sends over time". Required. */
label: string;
/** Value formatter for axis ticks, point tooltips and the data table. */
valueFormat?: (n: number) => string;
/** Plot height in px. Width is always fluid (100% of the container); the SVG is drawn in
* real pixels at the container's measured width, so text never scales with the card. */
height?: number;
/** Number of horizontal gridlines (excluding the zero baseline). */
yTicks?: number;
state?: ChartState;
error?: string;
onRetry?: () => void;
className?: string;
}
/**
* TrendChart — a multi-series line chart over a shared category axis, with gridlines,
* axis labels and per-point tooltips. Colour comes only from `--chart-1..8` (MP-210):
* those tokens are identical across light, dark and high-contrast, so the lines need no
* theme-specific code; axis lines, gridlines and labels use the theme-aware `--border`
* and `--text-muted` tokens instead of a fixed grey.
*
* The SVG itself is decorative (`role="img"`, one accessible name for the whole
* picture) — the actual accessible interface is the keyboard-reachable legend below it
* (hover or focus a series to highlight its line and read its tooltip) plus the
* visually-hidden data table, which carries every value a sighted user can read off the
* lines. See chartInternal.tsx for both pieces, shared with the other three charts.
*/
declare function TrendChart({ categories, series, label, valueFormat, height, yTicks, state, error, onRetry, className, }: TrendChartProps): React.JSX.Element;
interface DonutSegment {
id: string;
label: string;
value: number;
/** Defaults to the categorical ramp in order. */
color?: string;
/** "quiet" draws the slice in the system's quiet colour (as SegmentedMeter's `variant: "quiet"`), so a
* remainder such as "No year" reads as background. An explicit `color` wins. */
variant?: "quiet";
}
interface DonutChartProps {
segments: DonutSegment[];
/** Accessible name for the chart, e.g. "Vendor source". Required. */
label: string;
/** Diameter in px. The ring is always fluid up to this size (100% of a narrower
* container), never wider. */
size?: number;
thickness?: number;
/** Big number in the centre. Defaults to the segment total. */
centerLabel?: string;
centerSubLabel?: string;
valueFormat?: (n: number) => string;
state?: ChartState;
error?: string;
onRetry?: () => void;
className?: string;
}
/**
* DonutChart — segments drawn as concentric `stroke-dasharray` arcs (no path/arc maths
* needed for a ring), a centre label, and a keyboard-reachable legend. Colour comes only
* from `--chart-1..8` (MP-210), which render identically in light, dark and high
* contrast, so the ring needs no theme-specific code.
*
* Matches the operator's reference front end (PLN-2 SMS-20): a ring with a percentage
* in the centre and a legend of `label · pct% · count` rows below it (vendor source,
* blocked-number breakdown).
*/
declare function DonutChart({ segments, label, size, thickness, centerLabel, centerSubLabel, valueFormat, state, error, onRetry, className, }: DonutChartProps): React.JSX.Element;
interface BarListRow {
id: string;
label: string;
value: number;
/** Defaults to the categorical ramp in order. */
color?: string;
}
interface BarListProps {
rows: BarListRow[];
/** Accessible name for the whole list, e.g. "Largest states". Required. */
label: string;
valueFormat?: (n: number) => string;
/** Percent is computed against this. Defaults to the sum of every row's value. */
total?: number;
/** How a row's share of `total` (a fraction, 0 to 1) is written: the row, its label and the data table. Defaults to whole percents. */
shareFormat?: (share: number) => string;
/** Sort descending by value before rendering. Default true — this IS a ranking. */
sort?: boolean;
/** Cap the number of rows drawn (after sorting). */
maxRows?: number;
barHeight?: number;
state?: ChartState;
error?: string;
onRetry?: () => void;
className?: string;
}
/**
* BarList — ranked rows with a label, a proportional bar, a value and a percent, the
* shape the reference front end draws for "Largest states" / "Vendor source by count"
* (PLN-2 SMS-20).
*
* Each bar is scaled against the *largest* row (so the top row is always full width and
* the rest read relative to it, matching the reference), while the percent shown next to
* the value is computed against `total` (the sum of every row, by default) — the two
* numbers answer different questions and both matter. Not built on `Meter`: `Meter`
* stacks segments of one whole into a single bar, but a bar list draws one independent,
* fully proportional bar per row, which is a different shape than `Meter` produces.
* Reused from it instead: the same chart colour ramp and formatters (`chartInternal`,
* itself built on `SegmentedMeter`'s `meterFormats`) and the same row-composition idiom
* as `SegmentedMeter`'s legend rows (swatch, label, value).
*
* Every row is a real `` wrapped in the kit's `Tooltip`, so the ranking is
* keyboard-reachable and each row's full detail is available as a tooltip even though
* the same numbers are already printed inline.
*/
declare function BarList({ rows, label, valueFormat, total, shareFormat, sort, maxRows, barHeight, state, error, onRetry, className, }: BarListProps): React.JSX.Element;
interface FunnelStep {
id: string;
label: string;
value: number;
}
interface FunnelChartProps {
/** In order, first to last — e.g. Sent, Delivered, Clicked, Opted-out. */
steps: FunnelStep[];
/** Accessible name for the whole funnel, e.g. "Send funnel". Required. */
label: string;
valueFormat?: (n: number) => string;
barHeight?: number;
state?: ChartState;
error?: string;
onRetry?: () => void;
className?: string;
}
/**
* FunnelChart — ordered steps drawn as a tapering stack (Sent -> Delivered -> Clicked ->
* Opted-out, the reference front end's own shape, PLN-2 SMS-20), each reporting its
* value, its step rate (conversion from the step before it) and its percent of the
* first step. Each step's label (name, value, rates) sits above its own bar. Colour comes only from `--chart-1..8` (MP-210), one per step in order.
*
* Every step is a real `` wrapped in the kit's `Tooltip`, keyboard-reachable
* exactly like `BarList`'s rows — a funnel is a ranked, ordered list before it is a
* picture, so it gets the same interaction.
*/
declare function FunnelChart({ steps, label, valueFormat, barHeight, state, error, onRetry, className, }: FunnelChartProps): React.JSX.Element;
interface ClampProps {
children?: React.ReactNode;
/** With maxChars, pass the raw string instead of children. */
text?: string;
maxChars?: number;
maxHeight?: number;
moreLabel?: string;
lessLabel?: string;
onToggle?: (open: boolean) => void;
className?: string;
}
/**
* Height- or character-clamped content with a Show more control.
* The control is a real button, always in the DOM when the content overflows.
*/
declare function Clamp({ children, text, maxChars, maxHeight, moreLabel, lessLabel, onToggle, className, }: ClampProps): React.JSX.Element;
interface CsiMeterProps {
band: "weak" | "adequate" | "strong" | "dominant";
size?: number;
width?: number;
gap?: number;
}
declare function CsiMeter({ band, size, width, gap }: CsiMeterProps): React.JSX.Element;
/**
* Campus Saturation Index — the product's signature encoding. Four ordered bands
* in a reserved teal ramp that appears nowhere else: blue is interaction, teal is
* saturation. Never color-only — the band name and a 4-segment meter always ship
* with the fill.
*/
interface CsiBadgeProps extends React.HTMLAttributes {
band: "weak" | "adequate" | "strong" | "dominant";
/** Cost per reachable person. Internal surfaces only — never client-facing. */
crp?: number;
size?: "compact" | "medium";
/** The "will become" state while a reallocation is being dragged: 70% opacity, dashed edge. */
preview?: boolean;
/** Amber dot marking a Weak campus that can be fixed. */
alert?: boolean;
}
declare function CsiBadge({ band, crp, size, preview, alert, className, ...rest }: CsiBadgeProps): React.JSX.Element;
interface CsiHeroProps {
band: "weak" | "adequate" | "strong" | "dominant";
crp?: number;
label?: string;
className?: string;
}
declare function CsiHero({ band, crp, label, className }: CsiHeroProps): React.JSX.Element;
/** What a column becomes when the table is narrow (640px or less), where each row is a compact card instead of a table row:
* `primary` is on the card's title line, `secondary` in its muted meta line (labelled with the
* column's label), `hidden` is left out. */
type DataTablePhoneRole = "primary" | "secondary" | "hidden";
/**
* The product's dense read surface. Sticky overline header, hairline rows, no zebra,
* numbers right-aligned and tabular. Renders its own skeleton rows so the table
* never collapses while data is in flight. At 640px wide or less (its own width, not the window's) each row becomes a compact card.
*/
/** A column's heading: plain text, or any node (a title with secondary text, say) together with
* `labelText`, its plain-text name, which the sort control's aria-label needs. */
type DataTableColumnLabel = {
label: string;
labelText?: undefined;
} | {
label: React.ReactNode;
labelText: string;
};
type DataTableColumn = DataTableColumnLabel & {
key: string;
/** Right-aligned tabular figures. Never center a number. */
numeric?: boolean;
/** Keeps this column's values on one line, in the table cell and in the phone card's meta line, so a value
* like "FOIA 2025-26" never breaks at its hyphen. A table too narrow for it scrolls sideways in its own wrapper. */
noWrap?: boolean;
/** Set widths explicitly; auto layout shifts as data arrives. */
width?: string;
sortable?: boolean;
/** The column's role in the stacked-card layout, at a table width of 640px or less. At least one column must be `primary`. */
phone: DataTablePhoneRole;
render?: (row: any) => React.ReactNode;
/** The phone card's form of this column's value, for when `render`'s is too long for a card (a reason that
* would crowd the meta line, say). Only the stacked card uses it; the table cell always uses `render`. */
phoneRender?: (row: any) => React.ReactNode;
};
/** One row's own action: an icon button that acts on that row (a download, say) and never opens it. */
interface DataTableRowAction {
/** Phosphor icon name, as IconButton takes it. */
icon: string;
/** The button's accessible name and tooltip, e.g. "Download original file". */
label: string;
onClick: () => void;
disabled?: boolean;
}
interface DataTableProps {
columns: DataTableColumn[];
rows: any[];
compact?: boolean;
/** Keys (row[rowKey]) of every selected row; each gets aria-selected and the selected styling. */
selectedIds?: ReadonlyArray;
/** Per-row title (native tooltip), e.g. "Click to add to / remove from filter". */
rowTitle?: (row: any) => string;
onRowClick?: (row: any) => void;
/** A control of each row's own. In the table it is the last cell of the row (under a visually hidden
* "Actions" header); in a phone card it is a button beside the card, not inside the card's own button.
* It is reachable by Tab, named by its label, and never triggers `onRowClick`. Return null for a row with
* no action. */
rowAction?: (row: any) => DataTableRowAction | null;
rowKey?: string;
/** First load: nothing on screen yet, so skeleton rows are drawn. */
loading?: boolean;
/** A re-query (sort, filter, page) while the PREVIOUS rows are still rendered. Dims them,
* makes them inert and shows an "Updating…" pill, so nobody acts on superseded data. */
refreshing?: boolean;
skeletonRows?: number;
sort?: {
key: string;
dir: "asc" | "desc";
};
onSort?: (key: string) => void;
className?: string;
}
declare function DataTable({ columns, rows, compact, selectedIds, rowTitle, onRowClick, rowAction, rowKey, loading, refreshing, skeletonRows, sort, onSort, className, ...rest }: DataTableProps): React.JSX.Element;
interface FilterFieldOption {
value: string;
label: string;
}
interface FilterFieldBase {
/** Filter id — the key this field reads/writes in `filters` and reports through
* onFilterChange/onFilterRemove. Matches useServerTable's setFilter(id, value). */
key: string;
/** Human label shown in the "add filter" list and on the field's chip, where it names the kind
* ahead of the value ("Label: value") unless the field has an `icon` to name it. */
label: string;
/** Phosphor icon name (e.g. "megaphone") drawn before the chip's text, so a chip reads as
* its kind at a glance. It names the kind for a sighted reader, so a chip with an icon and a
* value paints that value alone; its accessible name and tooltip stay the full "Label: value". */
icon?: string;
/** DS-55: gives the field's chip a small help button beside it. `summary` is one line saying what
* the help explains; it shows in a Tooltip on hover and focus and is the button's description.
* Pressing the button calls FilterBar's `onFieldHelp(key, button)`; FilterBar draws no help content. */
help?: FilterFieldHelp;
}
interface FilterFieldHelp {
summary: string;
}
interface FilterFieldText extends FilterFieldBase {
type: "text";
placeholder?: string;
}
interface FilterFieldSelect extends FilterFieldBase {
type: "select";
options: FilterFieldOption[];
}
/** A filter whose value is set from outside FilterBar, by a link or the host's own state (MP-312). Its
* chip paints `valueLabel` and carries an x that removes it; the chip opens no picker, and the field
* is never offered in the Filter menu, so a person can neither add nor edit it. The value in
* `filters` is whatever the host set (any non-empty string); the label is the host's to provide, and
* so are loading and error states (pass a placeholder label until the real one is known). */
interface FilterFieldFixed extends FilterFieldBase {
type: "fixed";
/** What the chip paints for the value, e.g. a sheet's name. */
valueLabel: string;
}
/** Include/exclude, shared by every field whose value is a set of options. */
type FilterOperator = "in" | "notIn";
interface FilterMultiSelectValue {
operator: FilterOperator;
values: FilterFieldOption[];
}
interface FilterResolveMatch {
token: string;
value: string;
label: string;
}
interface FilterResolveResult {
matched: FilterResolveMatch[];
unmatched: string[];
}
interface FilterFieldMultiSelect extends FilterFieldBase {
type: "multiSelect";
/** Sync option list — filtered client-side by the typed query. Omit when `loadOptions`
* is given; the two are mutually exclusive (async wins if both are somehow set). */
options?: FilterFieldOption[];
/** Async option source for long lists (schools, imports, …), called ~300ms after the
* last keystroke with the current query (possibly "") and an AbortSignal that fires
* once a newer query has superseded this call — pass it straight to `fetch`/whatever
* transport does the lookup so a superseded request is actually cancelled, not just
* ignored on arrival. A rejection (that isn't the abort itself) renders as a visible
* error with a Retry action; it is never folded into "no matches", since a search
* that FAILED is a different thing to tell the user than a search that found
* nothing. */
loadOptions?: (query: string, signal: AbortSignal) => Promise;
/** Paste-a-list as a second input mode for THIS SAME field — search and paste both
* build one FilterMultiSelectValue for one dimension, so a school found by typing and
* a school found by pasting a list land as two entries in the same set instead of two
* separate filters that would AND together. Presence of `resolve` is what turns on
* the Search/Paste mode switch; omit it for a search-only field. Never called with an
* empty token list, and tokens are de-duplicated before the call. */
resolve?: (tokens: string[]) => Promise;
placeholder?: string;
/** What the panel says when the field has nothing to pick at all: its option list
* (`options`, or what `loadOptions` returns for the empty query) is empty and nothing
* has been typed, e.g. "No contact lists yet." It replaces the generic "No matches",
* which would wrongly suggest a search came up short. The field stays in the Filter
* menu and the text is announced through the panel's status region. Once the user types
* and nothing matches, "No matches" still shows. Omit it for the generic text. */
emptyText?: string;
/** Placeholder for the paste textarea, when `resolve` is given. Falls back to `placeholder`. */
pastePlaceholder?: string;
/** Restrict which operators this field's toggle offers, e.g. `["in"]` for a dimension
* that only ever means "is any of" and never "is none of" — a single-operator list
* hides the toggle entirely and that operator applies unconditionally, so an app can
* express an "any"/"is"-only dimension exactly rather than showing a switch with
* nothing to switch to. Defaults to both `["in", "notIn"]`. */
operators?: FilterOperator[];
/** The most values one selection may hold, e.g. the server's own limit on an id list.
* validateFilterValue rejects a larger selection, search stops offering new options
* once the limit is reached, and a pasted list that would exceed it is refused with a
* visible error instead of being applied. Must be a whole number of at least 1;
* FilterBar throws a clear, field-naming error in development if it isn't. No limit
* when omitted. */
maxValues?: number;
}
interface FilterRangeValue {
min?: number;
max?: number;
}
interface FilterFieldRange extends FilterFieldBase {
type: "range";
/** Bounds enforced on each stepper/typed value — NOT the same as the filter's own
* min/max VALUE (FilterRangeValue), which is what the user is narrowing to. */
min?: number;
max?: number;
step?: number;
/** Requires whole numbers for both bounds. Off by default, the same opt-in NumberInput
* itself takes — a range field with a whole-number step (e.g. 10) may still want
* decimal bounds, so this is a deliberate choice, not inferred from `step`. */
integer?: boolean;
minLabel?: string;
maxLabel?: string;
minPlaceholder?: string;
maxPlaceholder?: string;
}
type FilterDateValue = {
kind: "withinDays";
days: number;
} | {
kind: "between";
from: string;
to: string;
};
interface FilterFieldDate extends FilterFieldBase {
type: "date";
/** Default days shown the first time "within the last N days" is picked — shown, not
* written: nothing is set until the user changes it or confirms it. Must be a whole
* number from 1 to `maxDays`; FilterBar throws a clear, field-naming error in
* development if it isn't. */
defaultDays?: number;
/** Upper bound enforced on the "within the last N days" input. Default 3650 (ten
* years), which is also a hard ceiling: a larger maxDays is treated as 3650 by
* validateFilterValue and the Days input, in production as well as development. Must
* be a whole number from 1 to 3650; FilterBar throws a clear, field-naming error in
* development if it isn't. */
maxDays?: number;
}
/** A declared filter field. Add a variant here (and a branch below) for a new value
* picker. text/select keep their original bare-string value shape unchanged, so an
* existing consumer needs no migration; every other variant's value is a small typed
* object of its own (see FilterValue). */
type FilterField = FilterFieldText | FilterFieldSelect | FilterFieldFixed | FilterFieldMultiSelect | FilterFieldRange | FilterFieldDate;
/** The value shape a given declared field reads/writes, so a consumer can type its own
* `filters` record precisely instead of `Record` — e.g.
* `Record<"schools", FilterValue>`. */
type FilterValue = F extends FilterFieldText ? string : F extends FilterFieldSelect | FilterFieldFixed ? string : F extends FilterFieldMultiSelect ? FilterMultiSelectValue : F extends FilterFieldRange ? FilterRangeValue : F extends FilterFieldDate ? FilterDateValue : never;
interface FilterBarProps extends Omit, "onChange"> {
/** Every filter a consumer can add. Order here is the order in the "add filter" list. */
fields: FilterField[];
/** DS-55: fires with the field's key when the help button on a selected chip whose field has `help`
* is pressed, with the pressed button element as the second argument so the app can anchor its help to it
* and return focus to it (Safari and Firefox on macOS do not focus a clicked button, so the app cannot
* read it from document.activeElement). The app opens its own help for that filter; the press does nothing else. */
onFieldHelp?: (key: string, button: HTMLButtonElement) => void;
/** The applied filters, and the single source of truth for them: FilterBar keeps no
* copy. Straight off useServerTable — a value of undefined/""/false reads as "not set",
* same convention that hook already uses for activeFilterCount. multiSelect/range/
* date values are objects instead (see isFieldSet). Every edit, and every paste that
* settles later, is computed from this prop as it stands at that moment; a panel seeds
* itself from it when it opens, and follows it if it changes while open. */
filters: Record;
/** Fires on every value edit. Maps 1:1 onto useServerTable's setFilter(id, value).
* FilterBar only ever calls this with a COMPLETE, valid value for the field — an
* emptied selection, a one-sided date range, an invalid numeric range, number text
* that isn't a valid value, or a cleared bound go through onFilterRemove (or nothing,
* when the filter isn't set) instead.
*
* FilterBar is a controlled component: apply this to your state synchronously, so the
* next render passes the new `filters`. Never apply it inside `startTransition` or
* after a delay; React's rule for controlled inputs applies here too. Number fields
* emit on every keystroke that leaves them valid, so defer the EXPENSIVE work instead:
* debounce the request this triggers, or read the filters through useDeferredValue. A
* host that keeps filters in the URL keeps them in memory as the source of truth and
* syncs the URL from that. */
onFilterChange: (key: string, value: unknown) => void;
/** Fires when a chip's × is clicked, or when an edit leaves a field with no valid
* value; never for a key that isn't set in `filters`. Maps 1:1 onto useServerTable's
* clearFilter(id). Apply it synchronously, exactly like onFilterChange. */
onFilterRemove: (key: string) => void;
/** Which edge the "add filter" popover aligns to. Default "left" — the trigger sits
* at the start of the bar, so its menu opens under its left edge. */
align?: "left" | "right";
/** Controls that sit at the bar's right edge, in a column of their own: a date range, an
* export button. They are laid out beside the Filter button and the chips and stay put
* however many chips wrap (they top-align to the Filter button, never to the chips), and
* drop below the chips on a row of their own, still right-aligned, when the bar is too
* narrow to hold them all on one line. */
actions?: React.ReactNode;
/** Shows a "Clear all" button, right after the Filter button (under it when the main column is too narrow to hold both), while any filter is applied; it never
* shows without one. Fires once, with no arguments: the host clears every filter in a
* single update (applied synchronously, like onFilterChange/onFilterRemove), so FilterBar
* never fans one click out into a removal per filter. */
onClearAll?: () => void;
/** Shows the number of applied conditions on the Filter button (each selected option of a
* multiSelect counts, every other applied filter counts once), and nothing at zero. Off by
* default. With every field applied there is nothing left to add, so the button, which
* normally goes away then, stays disabled while it carries a count. */
showActiveCount?: boolean;
/** True while the field options are still arriving (`fields` has nothing in it yet). The bar holds
* its whole layout instead of collapsing and then growing: the Filter button's place is reserved
* by a skeleton exactly the button's size, the chips area keeps its height, and `actions` stay
* live where they are. Announced as busy; no filter can be opened or edited until it is false. */
loading?: boolean;
/** What a screen reader hears while `loading`. */
loadingLabel?: string;
className?: string;
}
type FilterValidation = {
ok: true;
} | {
ok: false;
reason: string;
};
/**
* The one place every field kind's rules live. Pure, with no state, so FilterBar's own
* commit function, a consumer's code (e.g. to disable a submit button) and a test can
* all call it on any candidate value:
* - text: a string.
* - select: one of the field's own option values.
* - fixed: nothing; its value is the host's, so no edit is valid.
* - multiSelect: `{ operator, values }` with at least one `{ value, label }` option, no
* more than the field's `maxValues`, and an operator the field allows.
* - date, withinDays: a whole number of days from 1 to the field's maxDays, which is
* itself capped at 3650.
* - date, between: two real YYYY-MM-DD dates, `from` on or before `to`.
* - range: at least one bound; each present bound a finite number within the field's
* min/max, and a whole number when `field.integer` is set; min ≤ max when both are set.
*/
declare function validateFilterValue(field: FilterField, value: unknown): FilterValidation;
declare function FilterBar({ fields, filters, onFilterChange, onFilterRemove, onFieldHelp, align, actions, onClearAll, showActiveCount, loading, loadingLabel, className, ...rest }: FilterBarProps): React.JSX.Element;
interface InfoButtonProps {
/** What the explanation is about. The button's accessible name is "About ". */
label: string;
/** The explanation, shown in a Tooltip on hover or focus. */
info: React.ReactNode;
}
/**
* DS-54 — the small info button StatTile draws after its label, exported so a panel heading can
* carry the same one (a ghost, small `IconButton` with the info icon, wrapped in a `Tooltip`).
* StatTile renders this component, so there is one copy.
*/
declare function InfoButton({ label, info }: InfoButtonProps): React.JSX.Element;
/**
* MP-310 — a set of `StatTile`s that wraps only into even rows. `columns` is how many tiles the
* set holds on a full row; as the grid's OWN container narrows it drops 4 → 2 → 1 (3 → 1, or 2 → 1),
* never leaving one tile alone on a line, which is what the plain `fd-grid-stats` (an `auto-fit`
* that fits as many tiles as the width allows) does with four tiles at a width that fits three.
* Callers that want that fit-as-many behaviour keep using `fd-grid-stats`; it is unchanged.
*
* The root is the size container (`container-type: inline-size`, like `FilterBar`'s main
* column) and the tiles sit in a grid inside it, because a grid cannot query its own width.
* That makes the column count follow the box the grid is placed in, not the viewport.
* `className` and every native div attribute go on the root.
*/
interface StatGridProps extends React.HTMLAttributes {
/** Tiles per full row. 4 wraps 4 → 2 → 1; 3 wraps 3 → 1 (never 2 + 1); 2 wraps 2 → 1. */
columns: 2 | 3 | 4;
}
declare function StatGrid({ columns, className, children, ...rest }: StatGridProps): React.JSX.Element;
/**
* A flat figure tile — no shadow, no background color, never more than four in a row.
* Given `onPress` it becomes a button that can filter a list (MP-311).
*/
interface StatTileBaseProps {
label: string;
value?: React.ReactNode;
sub?: React.ReactNode;
/** "+12.4%" or "-3.1%" — the arrow direction always follows the sign (up for a
* positive delta, down for a negative one). Colour follows the sign too, UNLESS
* `invert` is set. */
delta?: string;
/** Set for a metric where a larger number is the bad outcome (an opt-out rate, an
* error rate, a churn rate, ...) — flips which sign colours as good (ok) vs bad
* (danger), without changing which way the arrow points. A rising opt-out rate is
* still an up arrow (the number really did go up) but rendered in the danger
* colour, not the ok one a plain increase would otherwise get. */
invert?: boolean;
loading?: boolean;
/** Thin one-line variant for stat rows above tables. */
compact?: boolean;
className?: string;
/** Explains the figure. An info button sits right after the label; hovering or focusing it
* shows this in a Tooltip. The tile's own accessible name stays the label. */
info?: React.ReactNode;
}
/** Display mode: a `div`, exactly as before. `pressed` and `disabled` mean nothing without `onPress`. */
interface StatTileDisplayProps extends StatTileBaseProps, React.HTMLAttributes {
onPress?: undefined;
pressed?: never;
disabled?: never;
}
/** Press mode: the tile is a real `` with an `aria-pressed` state, so a dashboard's tiles
* can filter the list below them. Enter and Space press it (native button behaviour); a pressed
* tile wears the brand border and fill. */
interface StatTilePressProps extends StatTileBaseProps, Omit, "onClick" | "type" | "disabled" | "value"> {
onPress: () => void;
/** Whether the list is currently filtered by this tile. Give it (true or false) only for a tile that toggles a filter: it sets `aria-pressed`. Omit it for a tile that acts without a state (it jumps somewhere), which is then a plain button. */
pressed?: boolean;
/** Shown but cannot be pressed (nothing to go to). A `loading` tile is never pressable either. */
disabled?: boolean;
}
type StatTileProps = StatTileDisplayProps | StatTilePressProps;
declare function StatTile(props: StatTileProps): React.JSX.Element;
declare function languageLabel(lang?: string): string;
/** Tokenise source into [{t, c}] where c is a token class or null for plain text. */
declare function tokenize(src: string, lang?: string): Array<{
t: string;
c: string | null;
}>;
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
lineNumbers?: boolean;
wrap?: boolean;
/** Fold past this many lines. 0 disables. Default 26. */
collapseAfter?: number;
copyable?: boolean;
actions?: React.ReactNode;
className?: string;
}
/**
* A fenced code block: language label, copy, soft-wrap toggle, optional line
* numbers, and a fold for long listings.
*/
declare function CodeBlock({ code, language, filename, lineNumbers, wrap: wrapDefault, collapseAfter, copyable, actions, className, }: CodeBlockProps): React.JSX.Element;
interface DiffBlockProps {
diff: string;
filename?: string;
language?: string;
collapseAfter?: number;
className?: string;
}
/**
* A unified diff. Renders +/- lines with their own gutters and a stat line —
* the shape a code change should always take in a transcript.
*/
declare function DiffBlock({ diff, filename, collapseAfter, className }: DiffBlockProps): React.JSX.Element;
interface MarkdownProps {
source: string;
className?: string;
codeProps?: Partial;
allowImages?: boolean;
headingOffset?: number;
/** Renderer for `[^n]` markers. */
renderCitation?: (marker: string) => React.ReactNode;
onLinkClick?: (e: React.MouseEvent) => void;
/** `body` (default) or `small`. */
size?: MarkdownSize;
/** `default` or `muted` (secondary text colour). */
tone?: MarkdownTone;
}
type MarkdownSize = "body" | "small";
type MarkdownTone = "default" | "muted";
/**
* Read-only markdown → React nodes. Headings, emphasis, links, lists (incl. task
* lists), quotes, tables, images, fenced code (handed to CodeBlock, `diff` to
* DiffBlock) and `[^n]` citation markers.
*
* Deliberately read-only: an editor in readonly mode carries a contenteditable,
* a context menu and popovers it will never use — per message.
*/
declare function Markdown({ source, className, codeProps, allowImages, headingOffset, renderCitation, onLinkClick, size, tone, }: MarkdownProps): React.JSX.Element;
/** Inline-only markdown, for labels and single-line contexts. */
declare function MarkdownInline({ source, className, onLinkClick, size, tone }: {
source: string;
className?: string;
onLinkClick?: (e: React.MouseEvent) => void;
size?: MarkdownSize;
tone?: MarkdownTone;
}): React.JSX.Element;
/**
* A segmented bar — budget split by channel, delivery against a flight.
* Unallocated space is hatched, so it looks unfinished rather than empty.
*/
interface MeterSegment {
label: string;
value: number;
/** Defaults to the categorical chart ramp in order. */
color?: string;
}
interface MeterProps {
segments: MeterSegment[];
unallocated?: number;
height?: number;
label?: string;
legend?: boolean;
className?: string;
}
declare function Meter({ segments, unallocated, height, label, legend, className }: MeterProps): React.JSX.Element;
declare function pluralizeUnit(n: number, singular: string, irregularPlural?: string): string;
/**
* The standard footer for a server-driven table: range readout, rows-per-page,
* and windowed page navigation. Presentational only — it holds no state.
*/
interface PaginationProps extends React.HTMLAttributes {
/** 1-indexed current page. */
page?: number;
pageSize?: number;
/** Total matching rows on the server, not the length of the current page. */
total?: number;
pageSizes?: number[];
onPageChange?: (page: number) => void;
/** Omit to hide the rows-per-page control. */
onPageSizeChange?: (pageSize: number) => void;
loading?: boolean;
/** Singular noun for the readout — "row", "campus", "placement". The
* component renders it singular or plural itself based on `total`. */
unit?: string;
/** Irregular plural for `unit`, when it isn't a bare "+s" — e.g. "campuses"
* for "campus". Omit when the regular plural is correct. */
unitPlural?: string;
className?: string;
}
/**
* Pagination — the standard footer for every server-driven table.
* Purely presentational: it reports intent through onPageChange / onPageSizeChange
* and never derives its own state, so it stays honest against a real endpoint.
*/
declare function Pagination({ page, pageSize, total, pageSizes, onPageChange, onPageSizeChange, loading, unit, unitPlural, className, ...rest }: PaginationProps): React.JSX.Element;
/** "just now" · "4 min ago" · "in 4 min" · "Wed 5:00 PM" · "12 Mar" */
declare function formatRelative(value: string | Date, now?: number): string;
/** Absolute, for the title attribute. */
declare function formatAbsolute(value: string | Date): string;
/** Clock time only — "3:42 PM". */
declare function formatClock(value: string | Date): string;
/** ms → "1m 34s" · "12.4s" · "840ms" */
declare function formatDuration(ms: number): string;
interface RelativeTimeProps {
value: string | Date;
prefix?: string;
refresh?: boolean;
className?: string;
}
/**
* A self-refreshing relative timestamp. Ticks no faster than the unit it shows,
* and stops entirely once the value is days old.
*/
declare function RelativeTime({ value, prefix, className, refresh, ...rest }: RelativeTimeProps): React.JSX.Element | null;
/**
* File intake helpers — generic, no chat vocabulary.
* Nothing here touches the DOM event lifecycle: extractors REPORT what they found,
* the host decides whether to consume the event.
*
* Shared by the forms group and the chat kit — promoted to a top-level shared kit
* since multiple component groups depend on it.
*/
/** The host library's file shape. Use it for every file everywhere. */
interface Attachment {
id: string;
name: string;
size: number;
mime: string;
url: string;
/** Local preview while uploading. */
blobUrl?: string;
/** Non-null and < 100 means uploading. */
progress?: number | null;
thumb?: {
url: string;
};
/** Failed upload. Takes priority over progress. */
error?: string;
/** Free-text replacement for the size line, e.g. "6 lines · pasted". */
meta?: string;
/** Present only before upload; strip it before sending. */
file?: File;
}
interface FileRejection {
file: File;
reason: "type" | "size";
message: string;
}
declare function formatBytes(n: number): string;
declare function extensionOf(name: string): string;
/** Phosphor icon name for a file: type first, extension second. */
declare function iconForMime(mime?: string, name?: string): string;
declare function isImage(mime?: string, name?: string): boolean;
/** accept-attribute semantics: ".png,image/*,text/plain" */
declare function acceptMatches(file: File, accept?: string): boolean;
/** Split a FileList against accept + size. Rejections carry a reason string, never a silent drop. */
declare function filterFiles(files: FileList | File[], opts?: {
accept?: string;
maxFileSize?: number;
}): {
accepted: File[];
rejected: FileRejection[];
};
/** Human name for a synthesised paste file: "Pasted text (380 lines).txt" */
declare function pastedTextName(text: string): string;
/**
* What a paste is carrying, in one pass. NEVER calls preventDefault — it returns
* `source: null` when nothing was produced, so the host can let the event fall
* through. That is what makes it reusable in any input.
*/
declare function extractClipboardFiles(event: ClipboardEvent, opts?: {
largePasteThreshold?: number;
accept?: string;
maxFileSize?: number;
name?: string;
}): {
files: File[];
source: "files" | "text" | null;
rejected: FileRejection[];
text?: string;
};
/**
* Local, immediately-renderable stand-in for a file being uploaded.
* Shape is the host library's file type — id/name/size/mime/url/progress/thumb/error.
*/
declare function toAttachment(file: File, extra?: Partial): Attachment;
declare function revokeAttachment(att: Attachment): void;
/**
* Namespace export — `const { formatBytes } = window..FileKit;`
*/
declare const FileKit: {
formatBytes: typeof formatBytes;
extensionOf: typeof extensionOf;
iconForMime: typeof iconForMime;
isImage: typeof isImage;
acceptMatches: typeof acceptMatches;
filterFiles: typeof filterFiles;
pastedTextName: typeof pastedTextName;
extractClipboardFiles: typeof extractClipboardFiles;
toAttachment: typeof toAttachment;
revokeAttachment: typeof revokeAttachment;
};
declare const meterFormats: {
count: (n: number) => string;
compact: (n: number) => string;
bytes: typeof formatBytes;
percentOf: (total: number) => (n: number) => string;
};
interface MeterSegmentInput {
id?: string;
label: string;
value: number;
variant?: "brand" | "neutral" | "quiet" | "ok" | "warn" | "danger" | "info";
/** Any CSS colour; overrides variant. Defaults to the chart ramp in order. */
color?: string;
}
interface SegmentedMeterProps {
total: number;
segments: MeterSegmentInput[];
label?: string;
/** Value formatter. meterFormats.compact | .count | .bytes */
format?: (n: number) => string;
height?: number;
legend?: boolean;
showTotal?: boolean;
showPercent?: boolean;
remainderLabel?: string;
onClick?: () => void;
className?: string;
/** Base id for the meter's aria-labelledby target; falls back to React.useId(). */
id?: string;
}
/**
* A capacity bar split into categories — a context window, a disk quota, a budget
* burn-down, a rate limit. Deliberately free of any AI vocabulary: it takes a total
* and labelled segments, and reports the remainder as free space.
*
* A zero or missing total reports 0%, never NaN%.
*/
declare function SegmentedMeter({ total, segments, label, format, height, legend, showTotal, showPercent, remainderLabel, onClick, className, id, }: SegmentedMeterProps): React.JSX.Element;
interface QuotaRowProps {
label: React.ReactNode;
percent: number;
note?: React.ReactNode;
tone?: "brand" | "ok" | "warn" | "danger";
className?: string;
}
/**
* One quota line: name, an optional reset note, a percentage and a bar.
* Rate limits, plan allowances, storage tiers.
*/
declare function QuotaRow({ label, percent, note, tone, className }: QuotaRowProps): React.JSX.Element;
interface ScoreMeterProps {
/** The score itself, 0-100. Shown as a plain integer next to the rail. */
score: number;
/** One segment per contributing thing, each already carrying its own colour. Widths
* are proportional to `value` out of 100, so what doesn't contribute reads as the
* empty remainder of the rail rather than as a missing segment. */
segments: MeterSegmentInput[];
/** Accessible name for the meter, e.g. "Preference score". Required — a bare bar with
* a number next to it is meaningless to a screen reader without one. */
label: string;
/** Rail width in px. A fixed width (not a flexing one) is what lets a column of these
* line up down a list. */
width?: number;
height?: number;
/** Hide the numeral and show only the rail, for somewhere the number is already said. */
showScore?: boolean;
/** Content of the breakdown popover. Supplying it is what makes the meter clickable —
* without it this renders as a plain, inert span. */
breakdown?: React.ReactNode;
/** Accessible name for the popover. Defaults to `label` + " breakdown". */
breakdownLabel?: string;
placement?: Placement;
/** Width of the breakdown popover in px. */
breakdownWidth?: number;
className?: string;
}
/**
* ScoreMeter — a small 0-100 score drawn as a categorical bar, optionally opening a
* breakdown of how the score was arrived at.
*
* Not a new meter primitive: the rail IS SegmentedMeter (chosen over CsiMeter because a
* score built from a variable number of differently-coloured contributions needs
* per-segment colour and a variable segment count, and CsiMeter is fixed at four
* segments all drawn in one `currentColor`) with its head and legend turned off, so the
* proportional-width maths, the transition, the empty-remainder rail and the full
* `role="meter"` aria that SegmentedMeter already ships are used rather than reimplemented.
* What this adds is the part SegmentedMeter deliberately lacks: a real trigger.
*
* The trigger is a ``, never a div with an onClick — three things follow from
* that, and all three are the reason it's a button. It is keyboard-operable and
* announced as a control; SegmentedMeter's own `onClick` (which lands on a plain div)
* is neither. And inside a drag source it is automatically safe: TransferList arms a
* drag on any row pointerdown EXCEPT one originating inside a `button`, so the meter
* stays clickable in a row you can also pick up and drag, with no extra coordination
* between the two components.
*
* The popover is rendered as this component's own child rather than hoisted, which is
* what makes it survive a virtualized list: the anchor and the open state live and die
* together, so a row scrolled out of the mounted window takes its popover with it
* instead of leaving an orphan pinned to a dead element. While the row IS mounted, the
* popover tracks its anchor frame by frame (see usePopoverPosition), so it follows a row
* that slides to a new slot when the list is re-sorted underneath it.
*/
declare function ScoreMeter({ score, segments, label, width, height, showScore, breakdown, breakdownLabel, placement, breakdownWidth, className, }: ScoreMeterProps): React.JSX.Element;
interface SortMenuField {
/** Sort key sent to the endpoint. */
key: string;
/** Human label shown in the menu and on the collapsed trigger. */
label: string;
/** Numeric fields read "lowest/highest first" instead of "A to Z". */
numeric?: boolean;
}
type SortDirection = "asc" | "desc";
/** One sort key and its direction. A sort is an ordered list of these, most significant first. */
interface SortEntry {
key: string;
dir: SortDirection;
}
interface SortMenuProps extends Omit, "onChange"> {
fields: SortMenuField[];
/** The current sort, most significant key first, straight from the endpoint's response
* envelope. Empty (the default) reads "Unsorted". A single-key sort is a list of one. */
sort?: SortEntry[];
/** Called with the complete new sort whenever the user changes it. The menu never holds
* the list itself: the host applies this, and `sort` comes back. */
onSortChange?: (sort: SortEntry[]) => void;
/** Sort by several keys at once: the panel lists the active keys in order, each with a
* direction flip, move earlier/later and remove, and every unused field offers an add.
* Off (the default), the sort is one key: choosing a direction on a field replaces it. */
multiple?: boolean;
/** Which edge the popover aligns to. Default "right". */
align?: "left" | "right";
/** Trigger button height, same scale as every other control in the kit (Button,
* Input). Default "md" — matches Input/SearchField's own default height, since
* this is most often placed directly alongside a SearchField in one header row
* (see TransferList) and the two need to line up. Pass "sm" for a denser,
* standalone placement. */
size?: "sm" | "md";
className?: string;
}
/**
* SortMenu — the sort control for every server-driven table.
*
* A raw "sort=crp&dir=desc" readout tells a user what the machine is doing, not what
* they asked for. This states the field in words and offers both directions as explicit
* targets, so choosing "highest first" is one click rather than a toggle you have to
* click twice and then verify.
*
* Direction wording follows the field type — numeric fields read "lowest/highest first",
* text fields "A to Z" — while both use the same arrow pair, because varying the icon by
* type encodes a distinction nobody asked for and reads as inconsistency.
*
* The sort is an ordered list of keys. By default it holds one; with `multiple` the user
* builds a list (first key wins, later keys break its ties) and can flip, reorder and
* remove each entry.
*
* Purely presentational: reports intent through onSortChange(list) and never holds sort
* state, so it stays honest against a real endpoint.
*/
declare function SortMenu({ fields, sort, onSortChange, multiple, align, size, className, ...rest }: SortMenuProps): React.JSX.Element;
interface Step {
id: string;
/** Short verb + target: "Queried calls table". */
label: string;
kind?: "tool" | "search" | "read" | "write" | "reasoning" | "run" | "fetch";
/** Raw input/output or reasoning text, behind a disclosure. */
detail?: string;
durationMs?: number;
status?: "running" | "ok" | "error";
}
/** Flat collapsible rows — never one card per tool call. */
interface StepListProps {
steps: Step[];
label?: string;
defaultOpen?: boolean;
open?: boolean;
onToggle?: (open: boolean) => void;
showDurations?: boolean;
dense?: boolean;
className?: string;
/**
* The work these steps belong to is not finished (a turn still waiting on background work). The head then
* reads as a neutral "Steps so far" disclosure, never the green "Worked for …" completion check, so a
* person never sees finished and working at once. A running step still shows its own spinner.
*/
active?: boolean;
}
/**
* A flat, collapsible list of work steps — one row per step, never one card per
* call. A long run stays readable: it is a list, not a stack.
*/
declare function StepList({ steps, label, defaultOpen, open: openProp, onToggle, showDurations, dense, className, active, }: StepListProps): React.JSX.Element | null;
/** Number of rows kept mounted above and below the visible band. Two buffers this size
* (one per edge) plus the visible band itself is what keeps the mounted node count
* bounded — see the file doc comment below for why 20 is the right number here. */
declare const VIRTUAL_LIST_BUFFER_ROWS = 20;
/** Default px of empty space left at the bottom of every row slot, which is what reads
* as the gap between two cards. See VirtualListProps.rowGap. */
declare const VIRTUAL_LIST_ROW_GAP = 6;
/** Where the caller's loaded slice sits inside the full collection. `firstItemIndex` is
* the absolute index of items[0]; `count` is how many are loaded. For a caller that
* holds the whole collection this is simply { firstItemIndex: 0, count: items.length }. */
interface VirtualLoadedBand {
firstItemIndex: number;
count: number;
}
interface VirtualWindow {
/** ABSOLUTE index of the first row a viewport of this height would actually show. */
visibleStart: number;
/** ABSOLUTE index of the last row a viewport of this height would actually show (inclusive). */
visibleEnd: number;
/** ABSOLUTE index of the first row that should be mounted in the DOM (visibleStart minus
* the buffer), clamped to the loaded band — a row the caller hasn't loaded can't mount. */
startIndex: number;
/** ABSOLUTE index of the last row that should be mounted in the DOM (inclusive). */
endIndex: number;
/** Full scrollable height in px — this is what makes the scrollbar/scroll range honest
* about the whole collection even though only a slice of it is ever mounted, and it is
* computed from the TOTAL count, never from how many rows happen to be loaded. */
totalHeight: number;
/** px the mounted block must be pushed down so its rows land at their real scroll position. */
offsetY: number;
}
/**
* Pure windowing math: given a scroll position and the shape of the data, decide which
* row indices are visible and which should actually be mounted. Kept free of React and
* the DOM on purpose — see VirtualList's doc comment for why this is exported and tested
* on its own rather than only indirectly through a mounted component.
*
* Every fixed-height virtualizer boils down to integer division: row i sits at
* `i * itemHeight` px, so scrollTop/itemHeight lands on the first partially-visible row
* and (scrollTop + viewportHeight)/itemHeight lands on the last one. Everything else here
* is clamping that to the buffer, to the real item count, and to whatever slice of it the
* caller currently has in memory.
*
* Every index this returns is ABSOLUTE — an index into the whole collection, not into the
* caller's loaded array. That distinction is the entire point of `loaded`: a caller that
* keeps a sliding window (say 50 rows retained out of 4,000, discarding rows as it scrolls
* rather than accumulating them) still gets row 3,214 drawn at 3,214 * itemHeight and a
* scrollbar sized for 4,000 rows. Compute positions off array indices instead and the
* whole list jumps every time the window slides.
*/
declare function computeVirtualWindow(opts: {
scrollTop: number;
viewportHeight: number;
itemHeight: number;
/** TOTAL rows in the collection, loaded or not. */
itemCount: number;
/** Which slice of that total is actually in memory. Defaults to "all of it". */
loaded?: VirtualLoadedBand;
bufferRows?: number;
}): VirtualWindow;
/**
* Given the current window and the LOADED band, says whether the visible band has come
* within one buffer's width of either edge of what's currently in memory — the signal
* VirtualList uses to ask its caller for more. Threading it through the *visible* band
* (not the mounted one) means the caller gets asked while the buffer still has rows left
* to show, not only once the user has scrolled past all of them.
*
* Measured against the loaded band rather than the total, because "do I need to fetch"
* is a question about memory, not about the collection: a caller holding rows 800-849 of
* 4,000 needs more in BOTH directions even though it is nowhere near either end.
*/
declare function computeNeedMore(win: Pick, loaded: VirtualLoadedBand, bufferRows?: number): {
start: boolean;
end: boolean;
};
interface VirtualListProps {
/** Whatever the caller currently has loaded — not the full remote collection. */
items: T[];
/** Fixed row height in px. Every row is assumed to be exactly this tall. */
itemHeight: number;
/** px of the row's slot left empty at the bottom, which is what reads as the gap
* between two cards. A real prop rather than a constant because the right gap depends
* on how much the row itself weighs: a one-line 44px row wants the 6px default, a
* two-line card wants more air. Note it comes OUT of itemHeight — the slot is still
* exactly itemHeight tall, so the scroll math is untouched by changing this. */
rowGap?: number;
/** Called with the ABSOLUTE index of the row (its index in the whole collection), which
* is not the same as its index in `items` once a sliding window is in play. */
renderItem: (item: T, index: number) => React.ReactNode;
/** Fired when the visible band comes within one buffer's width of an edge of what's
* loaded and that edge isn't known to be exhausted. The caller owns fetching — this
* only asks. The window is handed over too so a caller running a sliding window can
* answer a big scrollbar jump (visible band nowhere near the loaded band) by fetching
* around `win.visibleStart` instead of stepping a page at a time towards it. */
onNeedMore: (direction: "start" | "end", win: VirtualWindow) => void;
/** Which edges of `items` still have more behind them. Omitted/undefined reads as "yes,
* there might be more" for that edge, since an unknown edge is the common starting state. */
hasMore?: {
start?: boolean;
end?: boolean;
};
/** Absolute index of `items[0]` in the full collection. Non-zero when the caller keeps a
* sliding window and has discarded the rows above. */
firstItemIndex?: number;
/** Size of the WHOLE collection, when the caller knows it and is holding only a slice.
* Defaults to firstItemIndex + items.length, i.e. "what's loaded is all there is". This
* is what the scrollbar is sized against, so a caller that discards rows as it scrolls
* gets a scroll range that stays still instead of shrinking under the user. */
totalCount?: number;
/** Suppresses onNeedMore while a fetch the caller already started is in flight. Drives the
* small pinned spinner at the bottom of the list — rows already mounted stay exactly as
* they are, since this only ever means "more is being appended," never "what's on screen
* might be stale." For the append-vs-replace distinction, see `refreshing` below. */
loading?: boolean;
/** True while the caller is replacing the WHOLE result set (a search or sort change), as
* opposed to `loading` appending more onto the end. `items` is expected to still be the
* PREVIOUS result set while this is true — this renders it underneath a full-panel scrim
* instead of clearing to empty, so a search/sort edit never blanks the list while the new
* page is in flight. Swap `items` to the new rows once they land and this back to false. */
refreshing?: boolean;
/** Copy shown in the `refreshing` overlay's centered card, next to its spinner. */
refreshingLabel?: string;
keyOf: (item: T) => string | number;
/** Viewport height in px. Default (400) is ten rows at the kit's --control-h-md (44px). */
height?: number;
emptyState?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* VirtualList — a fixed-height windowed viewport for a list the caller may be streaming
* in from a few thousand rows at a time.
*
* Why fixed row height only: variable-height virtualization needs a measurement pass
* (render off-screen, read the real height, then correct the scroll math) which is a
* meaningfully bigger and more fragile piece of engineering than this kit needs for any
* consumer it has today. Every current use case is a single-line-or-two row of known,
* consistent height, so the simplest correct approach is the right one: the caller states
* itemHeight, and the math is plain arithmetic (see computeVirtualWindow).
*
* Why plain onScroll + scrollTop math instead of IntersectionObserver: an observer would
* still need one sentinel per boundary and a ResizeObserver alongside it to keep the
* scroll track honest as itemCount changes — no less code, and it trades arithmetic you
* can unit test for browser timing you can't. scrollTop/clientHeight are synchronous and
* exact, so the windowing math stays a pure function (computeVirtualWindow, exported and
* tested on its own below) with the component itself doing nothing but reading scrollTop
* and rendering the slice that function names.
*
* The window keeps ~10 rows actually visible plus a VIRTUAL_LIST_BUFFER_ROWS-row buffer
* mounted above and below (~50 rows mounted at once at the default height), which is
* enough that fast wheel/trackpad scrolling never outruns the mounted band before the
* next onScroll fires, without ever mounting more than a small bounded slice regardless
* of how many thousand rows `items` holds.
*
* `items` does not have to be the whole collection, or even start at the top of it.
* `firstItemIndex`/`totalCount` let the caller hand over a SLIDING WINDOW — say 50 rows
* retained out of 4,000, fetching ten at a time in either direction and DISCARDING the
* rows that fall out the other side. Every position here is computed from the absolute
* index, so discarding rows above the viewport doesn't move the rows below it, and the
* scrollbar stays sized to `totalCount` rather than shrinking to whatever is still in
* memory. Keeping a window is strictly the caller's business — this component never
* decides what to drop, it only refuses to assume that `items[0]` is row zero.
*
* Fully controlled: this holds no item data and no page state, only the scroll position
* needed to compute the window. The caller owns fetching more rows (onNeedMore) and
* knowing when an edge is exhausted (hasMore).
*/
declare function VirtualList({ items, itemHeight, rowGap, renderItem, onNeedMore, hasMore, firstItemIndex, totalCount, loading, refreshing, refreshingLabel, keyOf, height, emptyState, className, style, }: VirtualListProps): React.JSX.Element;
interface EntityRowAction {
/** Phosphor icon name, e.g. "plus" */
icon: string;
/** Accessible name for the action button. */
label: string;
onClick: () => void;
/** Picks the fill — "add" is the constructive (ok) fill, "remove" the destructive
* (danger) fill, and no tone the brand fill. The button is always a filled IconButton
* (variant "soft": a lighter fill with a darker border, MP-52): it is the one action on
* the row, repeated down a long list, and has to be findable at a glance on a default
* card and on a danger-toned one alike. No effect on layout, so a caller can still pass
* any icon. */
tone?: "add" | "remove";
}
/** Width in px a metric cell takes when it doesn't ask for its own. Wide enough for a
* five-digit grouped number plus its glyph at --overline-size. */
declare const ENTITY_ROW_METRIC_WIDTH = 74;
interface EntityRowMetric {
/** Stable identity for the column. Falls back to `label`, which is what actually has
* to match between rows for a column to line up, so an id is only needed when two
* columns legitimately share a label. */
id?: string;
/** What the number means — "students", "ad units". Never rendered as running text:
* it is the metric's accessible name, so the row stays scannable as numbers while a
* screen reader still hears "1,240 students". Without a `tooltip` it is also the
* cell's native hover title. */
label: string;
/** The long-form explanation of this figure — what it counts, why it is zero, what an
* "unknown" means. Shown in the kit's Tooltip on hover, keyboard focus and tap, and
* exposed as the cell's accessible description (`label` stays its short name). A cell
* with a tooltip becomes a focusable button so the explanation is reachable without a
* mouse, and drops the native `title` so two tooltips never show at once. A button is
* also what keeps a tap on it from arming TransferList's row drag. */
tooltip?: React.ReactNode;
value: React.ReactNode;
/** Phosphor icon name shown before the value, e.g. "student". */
icon?: string;
/** Fixed cell width in px. Same value on the same column of every row is what makes
* the strip a real aligned column rather than text that happens to sit near text.
* It is the width a cell takes when the strip has room for every column at its width;
* in a strip too narrow for that, cells shrink toward their own figure first, so a
* narrow row loses alignment before it loses a figure. */
width?: number;
tone?: "default" | "muted" | "danger";
}
/**
* Everything about a row that is CONTENT rather than behaviour — what the row says, as
* opposed to what it does. Split out from EntityRowProps as its own type on purpose: a
* caller that renders the same row twice (TransferList renders every row a second time
* as the drag ghost) hands one of these around whole, so a prop added here can't be
* silently dropped by the second render site the way individually-spread props were.
*/
/** An icon shown before the row's title, itself the Tooltip trigger together with the
* title text (MP-52: "hovering EITHER the icon or the name" opens the same explanation —
* one hoverable/focusable region rather than two separate targets that could disagree). */
interface EntityRowTitleIcon {
/** Phosphor icon name, e.g. "warning". */
icon: string;
tone?: "danger";
/** The long-form explanation, shown in the kit's Tooltip on hover, keyboard focus and
* tap — never a native `title`, so it is never truncated to a browser tooltip. */
tooltip: React.ReactNode;
}
interface EntityRowContent {
title: string;
/** Renders before `title`, wrapped with it in the kit Tooltip. Drive this from the
* row's own existing state (e.g. the same signal that sets `tone: "danger"`) — it is
* not a second source of truth, just a second thing that one signal explains. */
titleIcon?: EntityRowTitleIcon;
/** A secondary line — a count, a subtitle, whatever the caller's data has that a raw
* title doesn't. Optional because plenty of rows are fine with just a name. Sits
* inline after the title; for numbers that must line up row-to-row use `metrics`. */
meta?: React.ReactNode;
/** An aligned strip of small numbers below the title — fixed-width cells, so the same
* column lands at the same x on every row. Rendering more than one number through
* `meta` instead would produce a ragged line that happens to contain numbers. */
metrics?: EntityRowMetric[];
/** A small interactive element pinned to the end of the metric strip — the row's
* score meter, a menu, a flag. Anything focusable belongs in a real ``: the
* row is a drag source, and TransferList's drag arming explicitly skips any
* pointerdown originating inside a button, which is what keeps this clickable. */
accessory?: React.ReactNode;
/** Whole-row state colour. "danger" repaints fill, border and title (e.g. a school
* with no sellable inventory) — the tone tokens are --erow-danger-*, defined for
* both themes. Default is the ordinary card treatment. */
tone?: "default" | "danger";
}
interface EntityRowProps extends EntityRowContent {
action?: EntityRowAction;
/** Marks the row a drag source: shows the grip icon and a grab cursor, and reports
* `onPointerDown` so the caller's own pointer-based drag (see TransferList) can pick
* the gesture up from there. Deliberately NOT the native HTML `draggable` attribute
* (see the file doc comment for why) — this row never starts a native browser drag. */
draggable?: boolean;
onPointerDown?: (e: React.PointerEvent) => void;
style?: React.CSSProperties;
className?: string;
}
interface EntityRowMetricsProps {
metrics: EntityRowMetric[];
className?: string;
style?: React.CSSProperties;
}
/**
* The aligned number strip, on its own so the SAME columns can be rendered outside a
* row — TransferList's per-side footer totals are literally this component with the
* same widths, which is the only way a totals line can stay lined up with the rows it
* totals without the two places agreeing on magic numbers by hand.
*/
declare function EntityRowMetrics({ metrics, className, style }: EntityRowMetricsProps): React.JSX.Element;
/**
* EntityRow — a single row for a name plus an optional aligned strip of numbers, an
* optional accessory and an optional action, meant to be stamped out by the hundreds
* inside a VirtualList.
*
* A real card per row, not flat text: its own surface distinct from the list's
* background (`--surface-2` against the list's `--surface`), a hairline border, a soft
* rest-state shadow that lifts on hover — the same `--r-md`/`--e-1`/`--e-2` elevation
* vocabulary Card itself uses (see styles/components.css's ".fd-erow" rules), just at
* the tighter radius a compact row reads better at than Card's own `--r-lg`. Height is
* still left to the caller (it fills 100% of its parent) because VirtualList is what
* actually fixes row height, and duplicating that number here would be one more place a
* future change has to remember to update — the card treatment lives entirely in
* padding/border/shadow, not height. The border is --erow-border rather than --border
* for a measured reason: the card and the panel behind it are 1.06:1 apart in light
* mode, so the inter-card gap is only visible if the hairline carries it.
*
* The row lays out as one or two lines depending on what it's given: title (plus inline
* `meta`) always, and a second line holding the metric strip and accessory only when
* there is one. That keeps a plain name-only row exactly as tall and as quiet as it
* always was while letting a richer row carry three numbers and a meter.
*
* The drag/drop split is deliberate: a row only ever needs to say "I am the thing being
* dragged" (draggable + onPointerDown), never "something was dropped on me" — nothing in
* this kit drops one row onto another to reorder it, only a row onto a list to move it
* there. Putting the drop handling on every row would mean wiring the same drop logic N
* times over instead of once on the list container that actually owns it (see
* TransferList's own doc comment for why it drives a pointer-based custom drag rather than
* native HTML5 DnD — in short, a native drag image is a static snapshot, not a live
* element, so it can't sustain a persistent tilt that follows the cursor), so this only
* exposes the source half of the contract: `onPointerDown` hands the gesture off to
* whatever the caller wants to do with it (TransferList starts tracking a drag once the
* pointer has moved past a small threshold, so a plain click still reads as a click).
* The action button exists precisely so drag-and-drop is never the only way to do the
* same move — pointer-only and keyboard users get an equally real affordance, not a
* degraded fallback.
*/
declare function EntityRow({ title, titleIcon, meta, metrics, accessory, tone, action, draggable, onPointerDown, style, className, }: EntityRowProps): React.JSX.Element;
type TransferListSide = "left" | "right";
interface TransferListSideProps {
/** Whatever this side currently has loaded — not its full remote collection. */
items: T[];
/** Total matching count on the server, if known — shown next to the label. */
total?: number;
loading?: boolean;
/** True while this side's WHOLE result set is being replaced (a search or sort
* change), not merely appended to. `items` should still be the previous result set
* while this is true — see VirtualList's own `refreshing` prop, which this threads
* straight through. */
refreshing?: boolean;
search: string;
onSearchChange: (value: string) => void;
sort?: {
key: string;
dir: "asc" | "desc";
} | null;
sortFields: SortMenuField[];
onSort: (key: string, dir: "asc" | "desc") => void;
onNeedMore: (direction: "start" | "end", win: VirtualWindow) => void;
hasMore?: {
start?: boolean;
end?: boolean;
};
/** Absolute index of `items[0]` in this side's full collection, and the size of that
* collection — threaded straight through to this side's VirtualList so each side can
* independently run a sliding window. See VirtualList's own docs. */
firstItemIndex?: number;
totalCount?: number;
/** Heading text for this side. Fully caller-supplied — this component has no opinion
* about what the two sides represent. Stays a plain string because it is also woven
* into the search field's and the move action's accessible names; anything richer
* than a name belongs in `header`. */
label?: string;
/** A slot above the search row, under the label. */
header?: React.ReactNode;
/** A slot below the list — where a running totals line for this side goes. A real slot
* rather than more `label`, because totals are structured content (typically an
* EntityRowMetrics strip using the same widths as the rows, so the columns line up
* with the numbers they total) and `label` is a string that gets concatenated. */
footer?: React.ReactNode;
/** Copy shown in the full-panel drop overlay while a drag is over this side. Falls
* back to a generic "Drop here" (plus `label`, if set) when omitted — set this for
* exact per-side wording (e.g. "Drop to add to audience" / "Drop to remove from
* audience"), since that phrasing is about what a drop here DOES, which only the
* caller's domain actually knows. */
dropLabel?: string;
}
interface TransferListProps {
left: TransferListSideProps;
right: TransferListSideProps;
keyOf: (item: T) => string | number;
/** Everything a row SAYS, as one object — title, meta, the aligned metric strip, the
* accessory, the row tone. One render prop returning one EntityRowContent rather than
* a render prop per field, because a dragged row is rendered a SECOND time as the
* ghost that follows the cursor: with per-field props that second call has to
* remember to pass each of them, and the version of this component that did exactly
* that quietly dropped every field but title and meta off the dragged card. Handing
* the same object to both render sites makes that class of bug unrepresentable.
* The row's `title` also names its +/- action, with the destination side's `label`:
* "Move Alpha to In audience". */
renderRow: (item: T) => EntityRowContent;
/** Fired by both a drag-drop and the equivalent +/- click. The caller owns actually
* moving the item between whatever backing lists it maintains — this component never
* mutates `left.items`/`right.items` itself. */
onMove: (item: T, from: TransferListSide, to: TransferListSide) => void;
/** Row height fed straight through to each side's VirtualList. Default (64) fits the
* two-line card an EntityRow becomes once it carries a metric strip and an accessory:
* a 56px card (title line + strip line, 10px of vertical padding) plus the 8px gap.
* A list of bare title-only rows should pass 44 (the kit's --control-h-md, which is
* what EntityRow's single-line form is tuned to) with a matching smaller rowGap. */
itemHeight?: number;
/** px of each row slot left empty, which is what draws the gap between two cards.
* Default 8 — see the --erow-border token note for why a card on this kit's surfaces
* needs both a real gap and a real hairline before the separation is visible. */
rowGap?: number;
/** Viewport height fed straight through to each side's VirtualList. */
listHeight?: number;
className?: string;
}
/**
* TransferList — a dual-list picker for moving items between two collections, by drag or
* by a click, over a search box and a sort control each side owns independently.
*
* Deliberately named "left"/"right" rather than e.g. "available"/"selected": the product
* this ships for is one particular use of it, but nothing about moving rows between two
* lists is specific to that use, and baking in "available"/"selected" (or worse, this
* kit's actual first consumer's own vocabulary) would make every *other* two-list picker
* this kit will ever be asked for a second component instead of a second set of props. A
* caller wanting domain words passes them through `label`/`dropLabel`.
*
* A pointer-based custom drag, not native HTML5 DnD, and not a library: this kit ships
* zero runtime dependencies on purpose, and a pointer-events implementation (pointerdown/
* pointermove/pointerup driving a portaled ghost element) is still zero-dependency — just
* more code than `draggable`. It replaced an earlier native-HTML5-DnD version once the
* spec called for a real "card lifts out of the list and tilts, following the cursor"
* feel: a native drag image (`dataTransfer.setDragImage`) is a static snapshot taken once
* at dragstart, not a live element, so it can't sustain a persistent rotation that tracks
* the pointer for the duration of the drag — only owning the pointer events ourselves can.
* Each row's pointerdown (via EntityRow's `onPointerDown`) arms a potential drag; nothing
* visually happens until the pointer actually moves past `DRAG_THRESHOLD_PX`, so a plain
* click (on the row, or on its +/- action button) is never mistaken for one. Once armed,
* window-level pointermove/pointerup/pointercancel listeners (not per-row ones, since the
* row being dragged gets replaced by a placeholder mid-drag) track the gesture to
* completion; which side the pointer is currently over is decided by hit-testing each
* side's own bounding rect against the pointer position, not by any per-row target.
*
* The +/- action on every row is not a fallback bolted on for accessibility box-ticking:
* it is the same onMove call a drop makes, exposed as a first-class equivalent, because
* drag-and-drop alone would silently exclude anyone not using a mouse.
*
* Each side owns a `header` and a `footer` slot in addition to its `label`. `label` stays
* a plain string because it is also woven into that side's search field and move-action
* accessible names and gets a count concatenated onto it; the slots are where real
* content goes — a running totals line in the footer, rendered with EntityRowMetrics at
* the same column widths the rows use, so the totals sit under the numbers they total.
*
* Fully controlled and stateless about the actual item sets: `left`/`right` are handed in
* whole, this never copies or reorders them, and the only state kept locally is which row
* is mid-drag and which side a drag is currently over — pure interaction feedback, thrown
* away the moment the drag ends. Each side may independently be a sliding window over a
* much larger collection (`firstItemIndex`/`totalCount`, threaded to its VirtualList).
*/
declare function TransferList({ left, right, keyOf, renderRow, onMove, itemHeight, rowGap, listHeight, className, }: TransferListProps): React.JSX.Element;
interface Fact {
/** What the fact is. Also its identity, so labels are unique within a list. */
label: string;
/** Any node: text, a badge, a link. */
value: React.ReactNode;
}
interface FactListProps extends Omit, "children"> {
facts: Fact[];
className?: string;
}
/**
* Label/value pairs as a compact grid: 2 to 4 columns as the list's own width allows, one column
* when it is narrow (a phone). Where CardRow stacks one pair per line, a summary of a handful of
* facts reads at a glance instead of filling the screen. The columns follow the width of the
* container it is placed in, not the viewport, so it holds in a narrow card on a wide screen too.
*/
declare function FactList({ facts, className, ...rest }: FactListProps): React.JSX.Element;
/** A state (or DC) with the FIPS code the US map geometry (us-atlas) identifies it by. `code` is its
* USPS postal abbreviation, the key a caller names it by. */
interface UsState {
fips: string;
code: string;
name: string;
}
/** Every code a map value can name: the 50 states and DC, in state-name order (the order the map's arrow
* keys and its table walk them). */
declare const US_STATE_CODES: readonly string[];
/** Whether `code` is one of `US_STATE_CODES`. A consumer whose data also carries territories or blanks
* filters with this before passing values, rather than keeping its own copy of the list. */
declare function isUsStateCode(code: string | null | undefined): code is string;
interface ChoroplethValue {
/** USPS postal code: "CA", "DC". */
state: string;
/** A whole, non-negative count. 0 reads as no value. */
value: number;
}
interface StateFigure extends UsState {
value: number;
/** Fraction (0 to 1) of every value given. */
share: number;
}
/** A share of the total for a label: whole percent from 10%, one decimal below it, "<0.1%" for a
* share too small to show one, and never "0%" for a state that has a value. */
declare function formatShare(share: number): string;
interface USChoroplethProps {
/** One entry per state that has a value: USPS code and a whole count. A state not listed, or 0,
* is drawn empty. A code that is not a state, a state twice or a value that is not a whole
* count throws. */
values: readonly ChoroplethValue[];
/** Accessible name of the map, e.g. "Students by state". */
label: string;
/** What the value counts, plural: the heading of the table's value column ("Students"). */
valueLabel: string;
/** A value as the legend, the ranking and the table print it. */
formatValue: (value: number) => string;
/** What one state reads as everywhere it is named: its shape's accessible name, the tooltip and
* the table. A state with no value arrives with `value` 0. */
formatReadout: (state: StateFigure) => string;
/** Show a ranking of this many states beside the map (below it when the component is narrow).
* Leave unset for no ranking. */
topN?: number;
/** What 100% is, for every share the component prints (readout, ranking, table). Defaults to the sum
* of `values`; pass a larger whole count when some of what you counted has no state. Smaller than
* the sum throws. */
total?: number;
className?: string;
}
/**
* USChoropleth: a US map with each state shaded by a count, on the kit's sequential ramp
* (`--seq-1..5`), with the legend that names what each shade means.
*
* The scale is equal steps in the square root of the count, so the many small states are told
* apart and the few huge ones do not take every dark shade. A state with no value is drawn empty,
* not as the lightest shade. Alaska and Hawaii are inset below the lower 48 (the geometry is
* us-atlas's Albers USA projection, already laid out that way, so no projection runs in the
* browser). Every state is a focusable shape named with its readout (the map is one tab stop and the arrow keys walk the states in name order, so a keyboard reader is not asked for 51 Tabs), shown as a tooltip on hover,
* keyboard focus or tap (`useTooltip`: a shape cannot be wrapped in `Tooltip`'s span).
*
* `topN` adds a ranking, a `BarList`, beside the map when the component is wide enough and below
* it when it is not: the component's own width decides (a container query), not the window's, since
* the same card sits in different page widths. Every figure is also in the table behind "Show as a
* table" (`Collapsible` and `DataTable`), which is the text alternative to the map.
*
* The outlines and the geo libraries (d3-geo, topojson-client, us-atlas) are one separate chunk,
* fetched when the map first renders; the legend, ranking and table show at once.
*/
declare function USChoropleth({ values, label, valueLabel, formatValue, formatReadout, topN, total, className }: USChoroplethProps): React.JSX.Element;
interface ReferenceEntry {
/** What is being defined: a column, a glossary word, an API field. Also its identity within its list. */
term: string;
/** A small type label beside the term, such as `STRING` or `ARRAY`. */
tag?: string;
/** Shown in full and wrapped, never truncated. */
description?: React.ReactNode;
/** Controls at the entry's right, e.g. a CopyButton. */
actions?: React.ReactNode;
}
interface ReferenceSection {
/** Identity of the section, unique within the list. */
id: string;
title: React.ReactNode;
/** One line under the title. */
subtitle?: React.ReactNode;
/** Controls in the section's heading row, outside the open/close button, e.g. a CopyButton for the section's name. */
actions?: React.ReactNode;
entries: ReferenceEntry[];
/** Uncontrolled initial state. Default closed. */
defaultOpen?: boolean;
/** Controlled state; pair with `onToggle`. */
open?: boolean;
onToggle?: (open: boolean) => void;
}
interface ReferenceListBase extends Omit, "children"> {
/** Render terms in the monospace face. */
mono?: boolean;
/** Show a labelled search box that filters by term or description text. */
searchable?: boolean;
/** The search box's label. Default "Search". */
searchLabel?: string;
/** Shown when a search matches nothing. Default "No matches". */
emptyMessage?: React.ReactNode;
className?: string;
}
type ReferenceListProps = (ReferenceListBase & {
entries: ReferenceEntry[];
sections?: undefined;
}) | (ReferenceListBase & {
sections: ReferenceSection[];
entries?: undefined;
});
/**
* A reference as a definition list: each entry is a term (with an optional type tag), its full
* wrapped description and optional actions. Entries can sit in collapsible sections, and an
* optional search filters by term or description (case-insensitive): while a query is typed
* every section with a match is open, empty sections are hidden, the match count is announced,
* and clearing the query puts every section back to the state it had before.
*/
declare function ReferenceList(props: ReferenceListProps): React.JSX.Element;
/**
* Multi-select control with a 44px comfortable hit area.
*/
interface CheckboxProps extends React.InputHTMLAttributes {
label: React.ReactNode;
description?: React.ReactNode;
/** Renders as a bordered selectable card — for the big, calm choices. */
card?: boolean;
indeterminate?: boolean;
}
declare function Checkbox({ label, description, card, indeterminate, className, ...rest }: CheckboxProps): React.JSX.Element;
/** One-of-many control. Share a name across the group. */
interface RadioProps extends React.InputHTMLAttributes {
label: React.ReactNode;
description?: React.ReactNode;
card?: boolean;
/** Trailing slot beside the label, e.g. a "Recommended" Badge. */
badge?: React.ReactNode;
}
declare function Radio({ label, description, badge, card, className, ...rest }: RadioProps): React.JSX.Element;
/** An immediate on/off setting — no Save button. Use Checkbox inside forms instead. */
interface SwitchProps extends React.InputHTMLAttributes {
label?: React.ReactNode;
description?: React.ReactNode;
}
declare function Switch({ label, description, className, ...rest }: SwitchProps): React.JSX.Element;
/**
* Labelled text field. 44px tall, 8px radius, hairline border, blue focus ring.
*/
interface InputProps extends Omit, "size" | "prefix"> {
label?: string;
help?: string;
/** Present = error state. Replaces help text. */
error?: string;
/** Static text inside the field, left — e.g. "$" */
prefix?: React.ReactNode;
/** Static text inside the field, right — e.g. "CPM" */
suffix?: React.ReactNode;
/** Phosphor icon name shown left of the input */
icon?: string;
/** Right-aligned tabular figures. Use for every money or count field. */
numeric?: boolean;
required?: boolean;
size?: "md" | "lg";
/** Inline spinner on the right — for async validation or a value still arriving. */
loading?: boolean;
/** Applied to the outer .fd-field element — this is where width belongs. */
style?: React.CSSProperties;
/** Applied to the outer .fd-field element, like `style` and for the same reason: that
* element is the field's box in whatever layout it sits in (a flex toolbar, a grid
* cell). The input box inside it is a child of a vertical flex column, so a sizing
* class landing THERE turns a row's `flex-basis` into a height. */
className?: string;
/** Applied to the inner input element. Rarely needed. */
inputStyle?: React.CSSProperties;
}
declare function Input({ label, help, error, prefix, suffix, icon, numeric, required, size, loading, disabled, id, className, style, inputStyle, ...rest }: InputProps): React.JSX.Element;
interface SearchFieldProps {
/** Current search text. */
value: string;
/** Fires with the new text on every keystroke — a plain string, not a raw
* DOM event, so this drops in above a filterable list with zero boilerplate. */
onChange: (value: string) => void;
placeholder?: string;
/** Fires when the clear ("x") button is pressed. Defaults to onChange(""). */
onClear?: () => void;
/** Applied to the outer .fd-field element — this is where width belongs. */
style?: React.CSSProperties;
/** Applied to the outer .fd-field element, like `style`. */
className?: string;
/** Visible field label, same as Input. Most search fields skip this and rely on placeholder. */
label?: string;
disabled?: boolean;
id?: string;
name?: string;
"aria-label"?: string;
}
/**
* SearchField — the one keyword field that belongs above a filterable list.
*
* A thin wrapper around Input: a magnifying-glass icon on the left (Input's
* own `icon` prop) and a clear ("x") button on the right that appears only
* once there's something to clear. The clear button rides in Input's
* existing `suffix` slot rather than inventing new inset layout, and its
* styling matches Select's clearable "x" (`ph-x-circle`, `all: unset`,
* `text-muted`) so every clear affordance in the kit looks the same.
*
* Controlled, string-in/string-out. Debouncing is the consumer's job (or a
* future concern) — building it in here would be hidden behavior a caller
* can't see or override.
*/
declare function SearchField({ value, onChange, placeholder, onClear, style, className, label, disabled, id, name, ...rest }: SearchFieldProps): React.JSX.Element;
/** Multi-line field. Same chrome as Input; vertical resize only. */
interface TextareaProps extends React.TextareaHTMLAttributes {
label?: string;
help?: string;
error?: string;
required?: boolean;
rows?: number;
/** Read-only plus `aria-busy`, with the same visible look as `disabled` (the
* `.fd-input-disabled` tokens) — for a value an in-flight request is about to replace
* while the field stays reachable. Unlike `disabled`, `busy` never sets the native
* `disabled` attribute: `readOnly` blocks editing without removing the control from the
* tab order or dropping focus that was already on it, so a keyboard user's in-progress
* action (e.g. Ctrl+Enter to resolve) still reaches it while busy. */
busy?: boolean;
/** Applied to the outer .fd-field element — this is where width belongs. */
style?: React.CSSProperties;
/** Applied to the outer .fd-field element, like `style` — the same rule as Input. */
className?: string;
}
declare function Textarea({ label, help, error, required, rows, disabled, busy, readOnly, id, className, style, ...rest }: TextareaProps): React.JSX.Element;
/** Numeric field with thousands formatting while unfocused, +/- steppers (press once for
* one step, press and hold to repeat with acceleration), ArrowUp/ArrowDown (Shift for
* `bigStep`), and inline min/max/integer validation that keeps an invalid typed value
* from ever reaching `onChange`. Every stepper and arrow result lands on the step grid
* (multiples of `step` counted from `min`, or from 0 when there is no finite `min`),
* computed in decimal-safe arithmetic, then clamped into the allowed range. */
interface NumberInputProps {
label?: string;
help?: string;
error?: string;
/** Element id — lands on the and on the label's `for`. Generated when
* omitted, so `label` is always the input's accessible name either way. */
id?: string;
/** Names an instance that has no visible `label` — a bare bound inside a range
* row whose heading lives elsewhere. Do not set both: aria-label OUTRANKS the
* associated , so a field carrying both is announced as something other
* than what it visibly says. */
"aria-label"?: string;
prefix?: string;
suffix?: string;
required?: boolean;
disabled?: boolean;
/** Lowest allowed value. A typed value below it is shown as invalid and never reaches
* `onChange`; the steppers and arrow keys stop at it. With `integer`, the lowest
* allowed value is `Math.ceil(min)`. */
min?: number;
/** Highest allowed value, enforced the same way as `min`. With `integer`, the highest
* allowed value is `Math.floor(max)`. */
max?: number;
/** Grid spacing for the steppers and arrow keys. Default 1. A press moves to the next
* grid point in its direction, so from an off-grid value it snaps onto the grid
* (step 10 from 3 goes up to 10, down to 0). Typed values are not snapped. */
step?: number;
/** Distance for Shift+ArrowUp/ArrowDown and for the accelerated phase of a held
* stepper, also snapped to the step grid. Default `10 × step`. */
bigStep?: number;
/** Requires a whole number. Off by default, so a whole-number `step` (e.g. cents in
* steps of 10) doesn't by itself block decimals. When set, a typed fraction is shown
* as invalid, the allowed range narrows to `[ceil(min), floor(max)]`, and a stepper or
* arrow result that falls between whole numbers is rounded in the direction of travel. */
integer?: boolean;
/** Thousands-format the display when not editing. Default true. */
format?: boolean;
value?: number | string;
/** Called with {target:{value}} where value is a number — or `undefined` once the
* field is cleared to empty. A cleared field is "no value", never `0`: the caller
* (e.g. FilterBar's range/date panels) tells an unset bound apart from a typed
* zero by this, not by testing for `0` itself. Never called with an invalid value. */
onChange?: (e: any) => void;
/** Called on mount and whenever the field's validity changes — true while the text is
* empty or a legal number, false while it is out of range, fractional (with
* `integer`), or not a number. Invalid text stays in the field, flagged, after blur, so
* a parent with its own Done button can keep it disabled until the text is fixed. */
onValidityChange?: (valid: boolean) => void;
placeholder?: string;
style?: any;
className?: string;
}
declare function NumberInput({ label, help, error, prefix, suffix, required, disabled, min, max, step, bigStep, integer, value, onChange, onValidityChange, format, className, style, placeholder, id, "aria-label": ariaLabel }: NumberInputProps): React.JSX.Element;
/** Rich popover select: animated listbox, keyboard nav + type-ahead, search over 8 options, option icons, descriptions, meta and groups. API-compatible with the old native select. */
interface SelectOption {
value: string;
label: string;
description?: string;
icon?: string;
meta?: any;
group?: string;
disabled?: boolean;
}
interface SelectProps {
label?: string;
help?: string;
error?: string;
/** Names the field when it has no visible `label` (the trigger is then named " "). */
"aria-label"?: string;
options: Array;
placeholder?: string;
required?: boolean;
disabled?: boolean;
/** Options still arriving — the control locks and shows a spinner. */
loading?: boolean;
/** Force the search box on/off; default: on when over 8 options. */
searchable?: boolean;
/** Show an x that clears back to placeholder. */
clearable?: boolean;
/** Pick several: `value` is an array, the popover stays open, options get
* checkboxes, and a footer offers Select all / Clear. */
multiple?: boolean;
/** With `multiple`, override the collapsed box text for 2+ selections. */
summary?: (values: string[]) => string;
value?: string | string[];
/** Called with {target:{value}} like a native select — an array when `multiple`. */
onChange?: (e: any) => void;
/** Applied to the outer .fd-field element — this is where width belongs. */
style?: any;
className?: string;
/** Element id — lands on the trigger button, with the label's own id derived
* from it. Generated when omitted, so `label` names the trigger either way. */
id?: string;
}
declare function Select({ label, help, error, options, placeholder, required, disabled, loading, value, onChange, searchable, clearable, multiple, summary, id, className, style, "aria-label": hiddenLabel }: SelectProps): React.JSX.Element;
/** Calendar popover date field. Click the title to drill month -> year; range mode picks start then end with live hover preview. Values are ISO strings ("2026-03-01"). */
interface DatePickerProps {
label?: string;
help?: string;
error?: string;
/** Names the field when it has no visible `label` (the trigger is then named " "). */
"aria-label"?: string;
required?: boolean;
disabled?: boolean;
/** false: value is "YYYY-MM-DD". true: value is {start, end}. */
range?: boolean;
value?: any;
/** Called with {target:{value}} — string, or {start,end} in range mode. */
onChange?: (e: any) => void;
placeholder?: string;
/** Element id — lands on the trigger button, with the label's own id derived
* from it. Generated when omitted, so `label` names the trigger either way. */
id?: string;
style?: any;
className?: string;
}
/** The bare calendar, for embedding in custom surfaces. months=2 renders two side-by-side months (used by range mode). */
interface CalendarProps {
value?: any;
range?: boolean;
onPick: (v: any) => void;
initialMonth?: string;
months?: number;
}
declare function Calendar({ value, range, onPick, initialMonth, months }: CalendarProps): React.JSX.Element;
declare function DatePicker({ label, help, error, required, disabled, range, value, onChange, placeholder, id, className, style, "aria-label": hiddenLabel, ...rest }: DatePickerProps): React.JSX.Element;
/** Analog clock-face time field: tap or drag the hand to set the hour, auto-advances to minutes; AM/PM chips; "Now" shortcut. Value is 24h "HH:MM". */
interface TimePickerProps {
label?: string;
help?: string;
error?: string;
required?: boolean;
disabled?: boolean;
/** 24h "HH:MM", e.g. "14:30". */
value?: string;
/** Called with {target:{value}}. */
onChange?: (e: any) => void;
placeholder?: string;
/** Element id — lands on the trigger button, with the label's own id derived
* from it. Generated when omitted, so `label` names the trigger either way. */
id?: string;
style?: any;
className?: string;
}
/** The bare clock face, for embedding (e.g. below a Calendar). */
interface ClockFaceProps {
value?: string;
onChange: (v: string) => void;
}
declare function ClockFace({ value, onChange }: ClockFaceProps): React.JSX.Element;
declare function TimePicker({ label, help, error, required, disabled, value, onChange, placeholder, id, className, style }: TimePickerProps): React.JSX.Element;
/**
* Continuous value control. In the media planner this is the reallocation slider:
* a live chip follows the handle while dragging and the commit is explicit.
*/
interface SliderProps {
label?: string;
min?: number;
max?: number;
step?: number;
value?: number;
onChange?: (value: number) => void;
/** Renders the value in the chip and header, e.g. v => "$" + v.toLocaleString() */
format?: (value: number) => string;
showChip?: boolean;
help?: string;
/** Element id — lands on the range input and on the label's `for`. Generated
* when omitted, so `label` is always the slider's accessible name. */
id?: string;
/** Names an instance that has no visible `label`. Do not set both: aria-label
* OUTRANKS the associated , so a slider carrying both is announced as
* something other than what it visibly says. */
"aria-label"?: string;
className?: string;
}
declare function Slider({ label, min, max, step, value, onChange, format, showChip, help, id, "aria-label": ariaLabel, className, ...rest }: SliderProps): React.JSX.Element;
/** Dual-thumb range slider: drag either thumb or the rail, value bubbles while dragging or focused, tick marks with labels, optional magnetic snap, keyboard (arrows, shift for 10%, Home/End). */
interface RangeSliderProps {
label?: string;
help?: string;
min?: number;
max?: number;
step?: number;
/** [low, high] */
value: [number, number];
onChange: (v: [number, number]) => void;
/** Minimum distance the thumbs keep between them. */
minGap?: number;
/** Tick stops; label is optional. */
marks?: Array<{
value: number;
label?: string;
}>;
/** Ordinal mode: unevenly spaced legal values (numbers, or {value,label}); the
* slider then travels in even steps between them and reports real values.
* Infinity is allowed for an open upper bound — give it a label like "No limit". */
stops?: Array;
/** Magnetically snap to nearby marks while dragging. */
snap?: boolean;
format?: (v: number) => string;
className?: string;
}
declare function RangeSlider({ label, min, max, step, value, onChange, minGap, marks, snap, stops, format, help, className, ...rest }: RangeSliderProps & Record): JSX.Element;
interface DropzoneProps {
onFiles?: (files: File[]) => void;
onReject?: (rejections: FileRejection[]) => void;
accept?: string;
maxFileSize?: number;
multiple?: boolean;
disabled?: boolean;
label?: string;
hint?: string;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* A drop target that wraps any content. Reports accepted files and rejections;
* it never uploads anything itself.
*/
declare function Dropzone({ onFiles, onReject, accept, maxFileSize, multiple, disabled, label, hint, children, className, style, }: DropzoneProps): React.JSX.Element;
interface FilePickButtonProps extends Omit {
label?: string;
icon?: string;
children?: React.ReactNode;
}
/** A file input behind any trigger. Resets between picks so re-picking the same file fires. */
declare function FilePickButton({ onFiles, onReject, accept, maxFileSize, multiple, disabled, label, icon, className, children, }: FilePickButtonProps): React.JSX.Element;
type FileUploadHandler = (file: File, onProgress: (pct: number) => void, signal?: AbortSignal) => Promise | void>;
/**
* Upload orchestration for a staged set of files: progress per file, per-file
* errors, retry, and cancel — the same behaviour an upload field has.
*
* `upload(file, onProgress, signal)` must resolve to `Partial` (at
* least the server-assigned `id`, typically `url` and `thumb` too) or throw.
* When the result carries an `id`, it REPLACES the local staged id — the id a
* host sends onward (`ChatComposer.onSubmit`) is therefore always the id the
* server gave the file, never the client-local placeholder `toAttachment`
* minted. Nothing here knows what a chat is; it is the app's upload handler
* that does.
*/
declare function useStagedFiles(upload?: FileUploadHandler, opts?: {
onUploaded?: (a: Attachment) => void;
onError?: (e: unknown, a: Attachment) => void;
}): {
items: Attachment[];
add: (files: File[] | FileList) => Attachment[];
remove: (a: Attachment) => void;
retry: (a: Attachment) => void;
clear: () => void;
busy: boolean;
setItems: (items: Attachment[]) => void;
};
/** Namespace export — useStagedFiles is a hook, so it cannot be capital-initial itself. */
declare const DropzoneKit: {
useStagedFiles: typeof useStagedFiles;
};
interface FileChipProps {
file: Attachment;
onOpen?: (f: Attachment) => void;
onRemove?: (f: Attachment) => void;
onRetry?: (f: Attachment) => void;
compact?: boolean;
className?: string;
}
/**
* A compact file row: icon or thumbnail, name, size, live upload progress,
* error state, and an optional remove control. One file component everywhere —
* images get real thumbnails, video gets a play badge, uploads get a bar.
*/
declare function FileChip({ file, onOpen, onRemove, onRetry, compact, className }: FileChipProps): React.JSX.Element | null;
interface FileTileProps {
file: Attachment;
onOpen?: (f: Attachment) => void;
onRemove?: (f: Attachment) => void;
maxHeight?: number;
className?: string;
}
/**
* An image-forward tile. Pasted screenshots belong here, not in a chip —
* the picture is the content.
*/
declare function FileTile({ file, onOpen, onRemove, maxHeight, className }: FileTileProps): React.JSX.Element | null;
/**
* One row of uniform tiles — the staging tray. Same size whatever the type:
* images crop to fill, documents show a truncated name and their size.
* Overflow scrolls sideways instead of growing the tray.
*/
interface FileStripProps {
files: Attachment[];
size?: number;
onOpen?: (f: Attachment) => void;
onRemove?: (f: Attachment) => void;
onRetry?: (f: Attachment) => void;
className?: string;
}
declare function FileStrip({ files, size, onOpen, onRemove, onRetry, className }: FileStripProps): React.JSX.Element | null;
/** Images as tiles, everything else as chips. */
interface FileGridProps {
files: Attachment[];
onOpen?: (f: Attachment) => void;
onRemove?: (f: Attachment) => void;
onRetry?: (f: Attachment) => void;
tiles?: boolean;
maxHeight?: number;
compact?: boolean;
className?: string;
}
/**
* A set of files: images as tiles, everything else as chips.
* `tiles={false}` forces the compact all-chips form for tight columns.
*/
declare function FileGrid({ files, onOpen, onRemove, onRetry, tiles, maxHeight, compact, className }: FileGridProps): React.JSX.Element | null;
interface MarkdownEditorHandle {
focus(opts?: FocusOptions): void;
blur(): void;
readonly element: HTMLElement | null;
getSelection(): {
start: number;
end: number;
};
replaceRange(start: number, end: number, text: string, caretAt?: number): void;
insert(text: string): void;
wrapSelection(before: string, after?: string, placeholder?: string): void;
prefixLines(prefix: string): void;
}
interface EditorTrigger {
type: "slash" | "mention";
query: string;
from: number;
to: number;
}
/**
* Rich markdown composer. A contenteditable whose text content IS the markdown
* source, one element per line — so headings can be bigger and bold can be bold
* without a mirror to keep in metric lockstep.
*
* Keydown ordering contract: the host sees the event FIRST (onKeyDown), may call
* preventDefault(), and the editor then checks defaultPrevented and skips its own
* handling. onPaste follows the same contract.
*/
interface MarkdownEditorProps {
value: string;
onChange?: (next: string) => void;
onSubmit?: (value: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
onPaste?: (e: React.ClipboardEvent) => void;
onTrigger?: (t: EditorTrigger | null) => void;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
maxLength?: number;
minRows?: number;
maxRows?: number;
autoFocus?: boolean;
/** Enter sends, Shift+Enter inserts a newline. Default true. */
submitOnEnter?: boolean;
ariaLabel?: string;
className?: string;
id?: string;
}
/**
* The composer field.
*
* Keydown ordering contract — the host sees the event FIRST:
* editor emits onKeyDown → host may call preventDefault()
* → editor checks event.defaultPrevented and skips its own handling
* Emitting after the internal handler would make the host's preventDefault
* arrive too late. onPaste follows the same contract.
*/
declare const MarkdownEditor: React.ForwardRefExoticComponent>;
interface CodeEditorDiagnostic {
/** 1-based line number the diagnostic starts at. */
line: number;
/** 1-based column the diagnostic starts at. Defaults to 1 (start of line). */
column?: number;
/** 1-based end line. Defaults to `line`. */
endLine?: number;
/** 1-based end column. Defaults to the end of `endLine`. */
endColumn?: number;
message: string;
severity?: "error" | "warning" | "info";
}
interface CodeEditorProps {
value: string;
onChange?: (next: string) => void;
label?: string;
help?: string;
error?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
placeholder?: string;
/** Diagnostics anchored to a line/column range — shown inline (underline +
* a message list under the editor) and announced to screen readers via an
* `aria-live` region. */
diagnostics?: CodeEditorDiagnostic[];
/** The documented key that moves focus out of the editor after Tab has
* started indenting. Shown as help text under the editor. Default true. */
showEscapeHint?: boolean;
minHeight?: number;
id?: string;
className?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
}
/**
* An editable, monospace SQL code editor: line numbers, syntax highlighting,
* a controlled value/onChange, disabled/read-only states, and diagnostics
* anchored to a line/column range — shown inline as an underline plus a
* message list, and announced to screen readers through an `aria-live`
* region. See the file header above for the engine choice and the
* keyboard-trap contract (Tab indents; Escape then Tab moves focus out).
*/
declare function CodeEditor({ value, onChange, label, help, error, required, disabled, readOnly, placeholder: placeholderText, diagnostics, showEscapeHint, minHeight, id, className, style, autoFocus, }: CodeEditorProps): React.JSX.Element;
interface RadioGroupOption {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
badge?: React.ReactNode;
disabled?: boolean;
}
/** Exactly one names the group: `label` renders its own label; `labelledBy` points at a heading the app already shows. */
type RadioGroupNaming = {
label: React.ReactNode;
labelledBy?: never;
} | {
label?: never; /** Id of a visible element that names the group (`aria-labelledby`); no label is rendered. */
labelledBy: string;
};
type RadioGroupProps = RadioGroupNaming & {
options: RadioGroupOption[];
value: string;
onChange: (value: string) => void;
/** Renders each option as a bordered card. */
card?: boolean;
disabled?: boolean;
name?: string;
className?: string;
};
/**
* A labelled `role="radiogroup"` over native radios sharing one name, so the browser supplies the
* WAI-ARIA walk: Tab enters on the checked radio, arrows move and select, wrapping at the ends.
*/
declare function RadioGroup({ label, labelledBy, options, value, onChange, card, disabled, name, className }: RadioGroupProps): React.JSX.Element;
interface FieldsetProps extends Omit, "disabled"> {
/** Disables every descendant control and marks the group aria-busy (an apply is in flight). */
busy?: boolean;
}
/** A native fieldset: `busy` disables all descendant controls at once, with `aria-busy`. */
declare function Fieldset({ busy, className, ...rest }: FieldsetProps): React.JSX.Element;
interface AccountMenuLink {
id: string;
label: string;
icon?: string;
href?: string;
/** Hidden unless the signed-in user holds this permission. */
perm?: string;
badge?: string;
onSelect?: () => void;
}
/** A named, permission-filtered section of links rendered below the top-level (ungrouped)
* `links` — e.g. "Administration", "Architecture", "Raw Data". A group is hidden entirely
* once every one of its links is filtered out by `perm`. */
interface AccountMenuGroup {
label: string;
links: AccountMenuLink[];
}
interface AccountMenuProps {
links?: AccountMenuLink[];
/** Named sections below `links`, e.g. Administration. Each entry is filtered by its own
* `perm`; a group with no visible links renders nothing. */
groups?: AccountMenuGroup[];
/** Preferred over href navigation — lets an SPA route without a page load. */
onNavigate?: (href: string | undefined, item: AccountMenuLink) => void;
/** May return a Promise — the sign-out item shows a spinner and disables itself until it
* settles. On success the menu closes; on rejection the menu stays open with an inline,
* announced error and a Retry (MP-221) — the session is unchanged, so closing here would
* tell the user they signed out when they didn't. Falls back to the injected session's
* own `signOut`. */
onSignOut?: () => void | Promise;
showRoles?: boolean;
/** Demo affordance: impersonate another member to see their UI. Default true. */
allowUserSwitch?: boolean;
className?: string;
}
/**
* The avatar button + popover every flytedesk app puts in its top right.
* Reads the signed-in user from the nearest SessionProvider; shows each `groups` section
* only to those who can use at least one of its links.
*/
declare function AccountMenu({ links, groups, onNavigate, onSignOut, showRoles, allowUserSwitch, className, ...rest }: AccountMenuProps): React.JSX.Element;
/** Whatever a session-aware component asks of the signed-in user, independent of any specific
* identity provider. `kits/session.ts`'s own demo `User` extends this with fixture-only
* bookkeeping (status, lastActive, …) that a real integration is never expected to supply. */
interface SessionUser {
id: string;
name: string;
email: string;
title?: string;
team?: string;
phone?: string;
bio?: string;
timezone?: string;
/** Identity is managed by an SSO provider — ProfilePage disables the email field with a
* different help string when true. */
sso?: boolean;
joined?: string;
notify?: Record;
/** Display-only role names — used by the demo "View as another member" list. Not read
* anywhere permissions are actually decided; `SessionContextValue.roles`/`can`/`hasRole`
* are the source of truth for the SIGNED-IN user. */
roles?: string[];
}
interface SessionRole {
id: string;
name: string;
}
/** One flytedesk app the signed-in user can reach — the "All flytedesk apps" switcher's row
* shape. The design system never fetches this list; it is supplied via `AppSwitcher`'s own
* `apps` prop or via `SessionContextValue.apps`. */
interface AppEntry {
key: string;
name: string;
url: string;
icon?: string;
/** True for the app the switcher itself is currently rendered inside. */
current?: boolean;
}
/** Demo-only affordance: impersonate another member to see their UI. Present ONLY on the demo
* provider — a real, injected session must never populate this — so AccountMenu can key its
* "View as another member" row on this field simply existing, rather than on a separate
* boolean prop a real integration could forget to turn off. */
interface DemoSessionControls {
allUsers: SessionUser[];
setActiveUser: (userId: string) => void;
}
interface SessionContextValue {
user: SessionUser;
/** The signed-in user's roles, for display (AccountMenu's/ProfilePage's role badges). */
roles: SessionRole[];
/** Every permission key the signed-in user holds. */
permissions: string[];
/** All of `permission` must be held. */
can: (permission: string | string[]) => boolean;
/** At least one of `permissions` must be held. */
canAny: (permissions: string[]) => boolean;
hasRole: (roleId: string) => boolean;
/** The app's real sign-out handler. AccountMenu's `onSignOut` prop, when supplied, still
* wins over this — this is the fallback every app gets for free. May return a Promise —
* AccountMenu shows a pending state on its sign-out item until it settles. */
signOut: () => void | Promise;
/** Persists a profile edit. Omit on a session that does not support self-service profile
* edits; ProfilePage then relies solely on its own `onSave` prop. */
saveProfile?: (patch: Partial) => void | Promise;
/** The flytedesk apps this user can reach, for `AppSwitcher`. Omit if the app instead
* passes `AppSwitcher`'s own `apps` prop directly. */
apps?: AppEntry[];
demo?: DemoSessionControls;
/**
* True while this snapshot's permissions cannot be verified right now — an identity
* provider or auth-store outage, never an ordinary initial load (that case has its own
* "loading" region elsewhere and never reaches a `SessionProvider` at all). Permission is
* then UNKNOWN, not denied: `can`/`canAny`/`hasRole` may still be defined and may still
* fail closed for any OTHER caller that reads them directly, but `Gate` checks this field
* FIRST and, when true, renders its pending state instead of evaluating them against a
* snapshot that might already be stale. Omit (or leave `false`) for a session an app never
* expects to go stale — every existing integration keeps its current behavior unchanged.
*/
pending?: boolean;
}
interface SessionProviderProps {
value: SessionContextValue;
children?: React.ReactNode;
}
/** Mount once near the app root with the real, signed-in session. Every session-aware
* component in this package reads from this provider and nothing else. */
declare function SessionProvider({ value, children }: SessionProviderProps): React.FunctionComponentElement>;
/** Reads the nearest `SessionProvider`. Throws when none is mounted — a session-aware
* component with nothing to read from is a wiring bug to surface immediately, not a state to
* silently render through. */
declare function useSessionContext(): SessionContextValue;
interface AppSwitcherProps {
/**
* The flytedesk apps this user can reach. Defaults to the injected session's own `apps`
* (`SessionContextValue.apps`) when omitted — this package never fetches the list itself;
* an app either passes it here or wires it into its `SessionProvider` value.
*/
apps?: AppEntry[];
/** Preferred over `url` navigation — lets an SPA route without a page load. */
onNavigate?: (app: AppEntry) => void;
className?: string;
}
/**
* The "All flytedesk apps" switcher every flytedesk app puts in its topbar, beside
* `AccountMenu`: a grid-icon trigger anchoring a list of the apps the signed-in user can
* reach, the current app marked and not selectable. Reuses `Popover`/`Menu` — the same
* keyboard-complete, ARIA-correct list every other menu-shaped control in this package is
* built on — rather than a bespoke list, so Home/End/type-ahead/arrow-key behavior is
* identical everywhere.
*
* Renders nothing when there are no apps to switch to (an empty switcher is not a control,
* it's a dead button), so a Topbar can render it unconditionally.
*/
declare function AppSwitcher({ apps: appsProp, onNavigate, className }: AppSwitcherProps): React.JSX.Element | null;
interface AppearanceMenuProps {
className?: string;
}
/**
* The appearance popover every flytedesk app puts in its topbar: theme, text size, high
* contrast, and a reduced-motion override on top of the OS's own `prefers-reduced-
* motion`. Fits `Topbar`'s `actions`/`overflowActions` slot (see Topbar.tsx) the same
* way `AccountMenu` does — an `IconButton` trigger anchoring a `Popover`.
*
* It only reads and writes the nearest `AppearanceProvider`'s store (persistence is that
* store's concern, see kits/appearance.ts); applying the prefs to `document.documentElement`
* is the provider's job, so it holds at every width whether or not this menu is mounted.
* Every visual effect is a CSS rule in this package's own styles (tokens.css) driven by
* the `[data-theme]`/`[data-text-scale]`/`[data-contrast]`/`[data-motion]` attributes
* the provider sets — no inline styles, no app-side CSS.
*/
declare function AppearanceMenu({ className }: AppearanceMenuProps): React.JSX.Element;
type ThemeMode = "light" | "dark" | "system";
type TextScale = "sm" | "md" | "lg" | "xl";
interface AppearancePrefs {
theme: ThemeMode;
textScale: TextScale;
highContrast: boolean;
/** Can only ever ADD reduced motion on top of the `prefers-reduced-motion` OS floor
* tokens.css already respects — turning this off never re-enables motion the OS
* itself asked to reduce. */
reduceMotion: boolean;
}
declare const DEFAULT_STORAGE_KEY = "fd-appearance";
declare const DEFAULT_PREFS: AppearancePrefs;
/** An app-supplied external store for `AppearancePrefs` — the same read/subscribe shape
* `React.useSyncExternalStore` expects, plus a `write`. Implement this over whatever a
* consuming app already has (a server-synced prefs cache, a Redux/Zustand store, …) to
* drive `AppearanceMenu` from that state instead of the default localStorage store. */
interface AppearanceStore {
/** Synchronous current value. Called on every render that might have changed it —
* must be cheap and must not allocate a new object unless the value actually changed
* (React compares by reference to decide whether to re-render). */
getSnapshot: () => AppearancePrefs;
/** Persist a full prefs object. `AppearanceMenu` always calls this with the complete,
* merged record — never a partial patch — so a store never needs to merge itself. */
write: (prefs: AppearancePrefs) => void;
/** Register a callback fired whenever the snapshot changes for a reason other than this
* store's own `write` call returning (e.g. another tab, or an app's async server
* hydration completing after mount). Returns its own unsubscribe. */
subscribe: (onChange: () => void) => () => void;
}
interface AppearanceStoreOptions {
/** localStorage (or `storage`) key these prefs are read from / written to. Default
* "fd-appearance". Namespace this per consuming app. */
storageKey?: string;
/** Swappable for tests or a non-browser environment. Default `window.localStorage`
* (read lazily — safe to import this module where `window` doesn't exist yet). */
storage?: Pick;
/** Overrides the built-in defaults (`system` / `md` / off / off) per field. */
defaults?: Partial;
}
/** "system" resolved against the OS's current setting. Pure aside from reading
* `matchMedia` — never throws in an environment without it (SSR, some test runners). */
declare function resolveTheme(mode: ThemeMode): "light" | "dark";
/** Reads stored prefs, falling back field-by-field to defaults — a missing key, a
* malformed JSON blob, or a value of the wrong shape/type each fall back individually
* rather than discarding the whole record (a bad stored value degrades, it never
* crashes the caller). Used internally by `createLocalStorageAppearanceStore`; exported
* as a low-level primitive for a caller building its own store on top of a similar
* key/value backend. */
declare function loadPrefs(opts?: AppearanceStoreOptions): AppearancePrefs;
declare function savePrefs(prefs: AppearancePrefs, opts?: AppearanceStoreOptions): void;
/** The default `AppearanceStore`: backed by `storage` (default `window.localStorage`)
* under `storageKey` (default `"fd-appearance"`). Same-tab writes notify this store's
* own subscribers synchronously; the browser's `storage` event keeps every OTHER tab
* in sync too (a tab never gets its own `storage` event for its own write, which is
* exactly why same-tab notification is handled separately here). The window listener is
* attached only while at least one subscriber is registered, and removed once the last
* one unsubscribes — a store an app creates per-mount never leaks a listener. */
declare function createLocalStorageAppearanceStore(opts?: AppearanceStoreOptions): AppearanceStore;
/** Sets every attribute this kit's tokens.css rules read: `[data-theme]` (resolved,
* never "system" itself — nothing downstream re-resolves it), `[data-text-scale]`,
* `[data-contrast="high"]` (present only when on — absent, not "normal", is the "off"
* state so a plain `[data-contrast]` selector alone is meaningless) and
* `[data-motion="reduce"]` (same absent-is-off shape). No inline styles: every visual
* effect is a CSS rule in this package's own styles, driven by these attributes alone. */
declare function applyPrefs(prefs: AppearancePrefs, root?: HTMLElement): void;
/** Applies `store`'s current snapshot to `root` — one synchronous, idempotent call. Run it
* once BEFORE the app's first React render (`main.tsx`) so a saved theme/text size/contrast
* paints from the very first frame, with no flash of the defaults while the bundle boots and
* the app mounts. It is the same application `watchAppearance` does on mount, so the two never
* disagree. */
declare function applyAppearance(store: AppearanceStore, root?: HTMLElement): void;
/** Keeps `root`'s appearance attributes equal to `store`'s snapshot for as long as it runs:
* applies the current value now, re-applies on every change the store reports (a menu write,
* another tab's `storage` event, an app's async server hydration) and, while the theme is
* "system", when the OS's own light/dark setting flips. Returns its stop function.
*
* This is the ONE place appearance reaches the DOM, and it is independent of every piece of UI:
* `AppearanceProvider` runs it for the app's lifetime, so a saved Appearance applies at every
* width whether or not `AppearanceMenu` is mounted (Topbar collapses it into a "More actions"
* menu on a phone, where it isn't mounted until opened — SMS-96). */
declare function watchAppearance(store: AppearanceStore, root?: HTMLElement): () => void;
/** [prefs, update] over an app-supplied `AppearanceStore`. Reads through
* `React.useSyncExternalStore`, so a snapshot that changes for ANY reason — this hook's
* own `update`, another tab, or an app's async server hydration landing after mount —
* re-renders with the new value. It does NOT touch the DOM: applying appearance is the
* app-wide `AppearanceProvider`'s job (`watchAppearance`), never a hook a menu happens to
* mount. Nothing here is a module-level singleton. */
declare function useAppearancePrefs(store: AppearanceStore): [AppearancePrefs, (patch: Partial) => void];
interface AppearanceProviderProps {
/** The app's one `AppearanceStore` — `createLocalStorageAppearanceStore({ storageKey })`
* for an app with no server-side prefs, or its own adapter over server-synced state. Create
* it ONCE, outside React (a module constant): the provider, `AppearanceMenu` and the
* pre-render `applyAppearance(store)` call in `main.tsx` all share this one instance. */
store: AppearanceStore;
children: React.ReactNode;
}
/**
* Owns applying the app's saved Appearance (theme, text size, contrast, reduced motion) to
* `document.documentElement` for the whole life of the app, and shares its store with
* `AppearanceMenu`. Mount it once, at the app root — above the session boundary, so the
* sign-in and loading screens carry the saved Appearance too.
*
* Applying is deliberately NOT the menu's job: Topbar collapses the menu into a "More actions"
* overflow that isn't mounted until it's opened (640px and narrower by default), so a menu that
* applied prefs would leave a saved dark theme unapplied after a reload on a phone (SMS-96).
* Here a menu write, another tab's `storage` event and an app's async server hydration all
* re-apply at every width. The first application runs in a layout effect, before the browser's
* first paint of the app; call `applyAppearance(store)` before `createRoot(...).render(...)` to
* also cover the time before React mounts.
*/
declare function AppearanceProvider({ store, children }: AppearanceProviderProps): React.JSX.Element;
/** The store of the nearest `AppearanceProvider`. Throws without one: an `AppearanceMenu`
* mounted outside a provider would write prefs nothing ever applies. */
declare function useAppearanceStore(): AppearanceStore;
interface EndpointSpec {
id: string;
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
path: string;
title: string;
purpose: string;
usedBy?: string[];
/** [lo, hi] ms. Doubles as the latency SLO the UI's loading states are designed around. */
latency: [number, number];
request?: unknown;
query?: unknown;
response: unknown;
notes?: string;
/** Marks a paged list returning the { rows, pager, sort, filter } envelope. */
isList?: boolean;
}
interface SpecModule {
id: string;
label: string;
icon?: string;
endpoints: string[];
}
interface ApiSpecBrowserProps {
spec?: EndpointSpec[];
/** The app's simulated transport: (id, params) => Promise. Powers "Try it". */
onRequest: (id: string, params: unknown) => Promise;
/** Screen → endpoints map. Rendered as the primary filter. */
modules?: SpecModule[];
/** Lifecycle grouping: [[label, endpointIds]] */
groups?: Array<[string, string[]]>;
title?: string;
kicker?: string;
lede?: string;
/** Where the spec lives and how to swap in the real transport. */
sourceNote?: string;
conventions?: Array<[string, string]>;
openQuestions?: Array<[string, string, string]>;
className?: string;
}
declare function Json({ obj }: {
obj: unknown;
}): React.JSX.Element;
/** Drop-in browser for a simulated API contract. Takes the spec as data. */
declare function ApiSpecBrowser({ spec, onRequest, modules, groups, title, kicker, lede, sourceNote, conventions, openQuestions, className, ...rest }: ApiSpecBrowserProps): React.JSX.Element;
type ProfileFieldType = "text" | "textarea" | "select" | "readonly";
interface ProfileFieldDef {
/** Draft/patch key. Also the key `onSave`'s draft carries this value under. */
key: string;
label: string;
/** Default "text". "readonly" renders like "text" but is never editable, regardless
* of any `onChange`. */
type?: ProfileFieldType;
/** Groups fields sharing the same value into one Card, titled by this group's name.
* Default group: "Identity". Fields are rendered in the order their group first
* appears, then in the order given within that group. */
group?: string;
/** Shown under the group's title — the first field in a group with one supplies it. */
groupDescription?: React.ReactNode;
placeholder?: string;
/** Static help text, or computed from the signed-in user (e.g. a different string for
* an SSO- vs. password-managed account). */
help?: string | ((user: SessionUser) => string);
/** Required for `type: "select"`. */
options?: string[];
/** For `type: "textarea"`. Default 3. */
rows?: number;
/** Forces read-only regardless of `type`. */
readOnly?: boolean;
/** Computes this field's initial value from the signed-in user. Default: the user
* property of the same name, coerced to a string (`String(user[key] ?? "")`) — set
* this when the field's key doesn't match a `SessionUser` property, or needs
* formatting. */
value?: (user: SessionUser) => string;
}
interface ProfileNotificationDef {
/** Draft key, read from/written into `notify: Record`. */
key: string;
label: string;
description?: string;
}
interface DeviceSession {
id: string;
device: string;
where: string;
when: string;
current?: boolean;
}
interface ProfilePageProps {
/** Defaults to the signed-in user from the injected session. */
user?: SessionUser;
/** The editable fields this app's profile has. Omit (or pass none) and no field cards
* render at all — only the avatar header and the Access card remain. */
fields?: ProfileFieldDef[];
/** The notification toggles this app has. Omit and the Notifications card doesn't
* render. */
notifications?: ProfileNotificationDef[];
/** Rendered after the field cards and before Notifications — for app-specific content
* that isn't a plain field, e.g. a link out to another app's own profile page for
* fields that live there instead. */
extra?: React.ReactNode;
/** Renders the "Change photo" button when supplied; omitted entirely otherwise. */
onChangePhoto?: () => void;
/** Persist edited fields/notifications through your own endpoint. Defaults to the
* injected session's `saveProfile` (the demo provider wires this to
* SessionKit.saveProfile); if neither is available, Save is disabled. Receives every
* field's current value under its `key`, plus `notify` when `notifications` is set. */
onSave?: (draft: Record & {
notify?: Record;
}) => void | Promise;
onNavigate?: (href: string) => void;
/** Renders the "Signed-in devices" card when supplied (an empty array still renders
* the card, showing none); omitted entirely otherwise — no fabricated device list. */
sessions?: DeviceSession[];
onSignOutEverywhere?: () => void | Promise;
className?: string;
}
/**
* The shared profile page: identity, working preferences, notifications, devices — all
* driven entirely by the `fields`/`notifications`/`sessions` this app supplies. Roles are
* shown but never editable — self-promotion is the reason RBAC exists.
*/
declare function ProfilePage({ user: userProp, fields, notifications, extra, onChangePhoto, onSave, onNavigate, sessions, onSignOutEverywhere, className, }: ProfilePageProps): React.JSX.Element;
/**
* MP-288: the one full-viewport screen every flytedesk ID app shows while its session is
* not usable. flytedesk ID apps never show a sign-in button page (PLN-2 R-14): app-kit's
* headless `SessionBoundary` signs the visitor in automatically, and renders the app only
* while the session is usable. For every other moment it calls `screen(state)`, and this
* component is what the app renders there —
*
* }>
*
* Purely presentational, and the dependency rule is one-way in both directions: this
* package never imports app-kit, and app-kit never imports this one. `kind`, `error` and
* `onRetry` are exactly the plain shape app-kit's `SessionScreenState` has, so the fit is
* checked structurally at the app's call site.
*
* Busy kinds show a spinner and a message in a polite live region. Failure kinds show
* `error` in a danger Banner (which announces itself as an alert) and a "Try again" button
* that calls `onRetry`.
*
* Focus: the screen replaces the whole app, so whatever held focus is gone. Focus moves to
* the heading when the screen appears, and whenever it switches between a busy and a failure
* state (a retried failure's "Try again" button, which held focus, unmounts). From a failure,
* the next Tab reaches "Try again".
*
* Announcements: a live region only announces changes made after it is in the page, and
* this screen usually mounts with its message already known. So the message is written
* into the (already mounted) region one effect later; every later change of kind updates
* the same region, so "Signing you in…" becoming "Your session expired — …" is announced.
*/
type SessionScreenKind = "signing-in" | "session-expired" | "signing-out" | "reconnecting" | "sign-in-failed" | "load-failed";
interface SessionScreenProps {
kind: SessionScreenKind;
/** A plain-language message. Set for `sign-in-failed` and `load-failed`, null otherwise. */
error: string | null;
/** The failure kinds' "Try again". Set for `sign-in-failed` and `load-failed`, null
* otherwise; a failure kind without it shows no button. */
onRetry: (() => void) | null;
/** The heading, e.g. "SMS" or "Media Planner". */
productName: string;
}
declare function SessionScreen({ kind, error, onRetry, productName }: SessionScreenProps): React.JSX.Element;
interface RoadmapTimelineProps {
/** Called with a flag key when the reader clicks one — route it to your flag admin. */
onOpenFlag?: (flagKey: string) => void;
/** Heading. Default "Roadmap". */
title?: string;
/** Replaces the default explanation of what the timeline covers. */
lede?: string;
}
/**
* A vertical, chronological map of what is wired to the real backend and what is next.
*
* Derived entirely from the feature-flag registry (`SessionKit.roadmap()`) — status is each
* flag's `implemented` field, ordering comes from its date, and blocking relationships come
* from its `dependsOn`. Nothing here is maintained separately, so it cannot drift from what
* the apps actually do.
*
* Shipped work sits above a "you are here" marker and upcoming work below it; the scroll
* container opens at that marker, so the first thing in view is what comes next.
*/
declare function RoadmapTimeline({ onOpenFlag, title, lede }: RoadmapTimelineProps): React.JSX.Element;
interface PermissionDeniedProps {
permission?: string | string[];
title?: string;
detail?: string;
compact?: boolean;
className?: string;
}
/** Explains a block rather than hiding it — silence reads as a bug and files a ticket. */
declare function PermissionDenied({ permission, title, detail, compact, className, ...rest }: PermissionDeniedProps): React.JSX.Element;
interface GatePendingProps {
compact?: boolean;
className?: string;
}
/** Neutral placeholder for a permission that cannot be verified right now (an identity
* provider or auth-store outage) — never a denial, and never the gated content itself,
* which may have been authorized under roles that are no longer current. */
declare function GatePending({ compact, className }: GatePendingProps): React.JSX.Element;
interface GateProps {
/** All of these must be held. */
perm?: string | string[];
/** At least one of these must be held. */
anyOf?: string[];
role?: string | string[];
/** Render nothing instead of an explanation — for chrome like toolbar buttons. Also
* governs the pending case: a `silent` gate renders nothing while pending too, rather
* than the default `GatePending` placeholder. */
silent?: boolean;
fallback?: React.ReactNode;
/** Rendered while `SessionContextValue.pending` is true, in place of the default
* `GatePending` placeholder. Never falls back to `fallback` or to evaluating
* `perm`/`anyOf`/`role` — a pending session's `can`/`canAny`/`hasRole` are not
* evaluated at all, since neither a grant nor a denial would be truthful. */
pendingFallback?: React.ReactNode;
compact?: boolean;
children?: React.ReactNode;
}
/** Permission gate. Denied by default shows PermissionDenied. Pending (the session's
* permissions can't be verified right now) shows GatePending instead — never a denial,
* and never the gated content itself. Reads the injected session (see
* kits/sessionContext.tsx) — re-renders whenever that context's value changes, so no
* separate subscription is needed here. */
declare function Gate({ perm, anyOf, role, silent, fallback, pendingFallback, compact, children }: GateProps): React.ReactNode;
interface FeatureGateProps {
flag: string;
fallback?: React.ReactNode;
children?: React.ReactNode;
/** Renders a "See the roadmap" link inside the ComingSoon banner when supplied. */
onRoadmap?: () => void;
/** Blur radius in px for the underlying, inert UI. Passed through to ComingSoon. */
blur?: number;
minHeight?: number | string;
/** Overrides the ComingSoon banner headline. Defaults to the flag's own label (or a generic one). */
label?: string;
/** "block" wraps a panel; "inline" wraps a single control (button, chip, menu item). Default "block". */
variant?: "block" | "inline";
/** When false, an incomplete-in-live-mode feature renders `fallback` instead of the ComingSoon banner. Default true. */
preview?: boolean;
/**
* Permission that lets the viewer bypass the ComingSoon veil and use the feature early.
* Default `"flags.manage"`. Pass `null` to declare the gate NOT bypassable by anyone,
* regardless of permissions — the veil still renders, but no "View and use it anyway"
* control appears, and a bypass stored from before this call site turned `null` is
* ignored rather than honored. Use this for a gate whose content is itself the problem
* (e.g. fixture/demo data standing in for another record), where letting even an
* administrator click through would show something untrue, not just something unfinished.
*/
bypassPerm?: string | null;
}
/** Feature gate. ALWAYS silent when off — an unreleased feature should leave no trace. */
declare function FeatureGate({ flag, fallback, children, onRoadmap, blur, minHeight, label, variant, preview, bypassPerm }: FeatureGateProps): React.ReactNode;
/** Inline "why is this disabled" affordance for buttons that must stay visible.
* MP-253: while the session is pending, permission is unknown, not denied — the same rule
* Gate applies. Rendering the control live would risk a stale-role grant; rendering the
* "Requires X" denial would assert a permission gap that might not be real. So it dims the
* control (never `can()`), with a title that says the state is unverified rather than
* claiming a specific permission is missing. */
declare function PermissionHint({ perm, children }: {
perm: string | string[];
children?: React.ReactNode;
}): React.ReactNode;
interface ComingSoonProps {
/** Banner headline. Default "Coming soon". */
label?: string;
/** Target date, already formatted for display. */
eta?: string | null;
/** Rough size of the remaining work, e.g. "3 weeks". */
effort?: string;
/** What has to be built on the server for this to go live. */
backend?: string | null;
/** One line describing the feature itself. */
detail?: string;
/** Blur radius in px. Default 3 — enough to stop reading, not enough to hide the shape. */
blur?: number;
minHeight?: number | string;
/** "block" wraps a panel; "inline" wraps a single control (button, chip, menu item). */
variant?: "block" | "inline";
/** Renders a "See the roadmap" link when supplied. */
onRoadmap?: () => void;
/**
* Flag key identifying this feature. Supply it together with `canBypass` to offer a
* "View and use it anyway" escape — the choice is remembered for the session (sessionStorage,
* not localStorage: a bypass that survives days becomes the silent default).
*/
bypassKey?: string;
/**
* Whether this viewer may unblur. Gate on a permission the people building the feature hold
* (`flags.manage`). While bypassed the feature is fully interactive, with a persistent,
* non-dismissible bar stating that writes go to the simulated backend.
*/
canBypass?: boolean;
children?: React.ReactNode;
className?: string;
}
/**
* A feature that is designed and working but not yet wired to the real backend: the real UI,
* blurred, behind a construction banner, and fully inert (`pointer-events`, the `inert`
* attribute, and `aria-hidden`). Lets people see what is coming without acting on it.
*
* Usually reached through `FeatureGate`, which resolves the flag state for you. Use this
* directly only when the gating decision is already made elsewhere.
*/
declare function ComingSoon({ label, eta, effort, backend, detail, blur, minHeight,
/** "block" wraps a panel; "inline" wraps a single control (button, chip, menu item). */
variant, onRoadmap,
/** Flag key. Supplying it AND `canBypass` lets the viewer unblur and use the feature. */
bypassKey, canBypass, children, className, ...rest }: ComingSoonProps): JSX.Element;
declare function formatEta(iso?: string | null): string | null;
declare const FormatEta: typeof formatEta;
interface ModeSwitchProps {
/** Render nothing unless true. Pass SessionKit.can("flags.manage"). */
canToggle?: boolean;
/** Simulated requests this session — shown as a count badge in test mode. */
requestCount?: number;
onClearLog?: () => void;
/** Request-log body, rendered inside the popover in test mode only. */
renderLog?: () => React.ReactNode;
onOpenSpec?: () => void;
summary?: {
total: number;
complete: number;
incomplete: number;
endpointsWired: number;
};
}
/** Header control: lightning in live mode, flask + request count in test mode. */
declare function ModeSwitch({ canToggle, requestCount, onClearLog, renderLog, onOpenSpec, summary }: ModeSwitchProps): React.ReactNode;
interface TestModeBarProps {
onExit?: () => void;
}
/** Persistent strip shown only in test mode. Render at the top of the app frame. */
declare function TestModeBar({ onExit }: TestModeBarProps): React.ReactNode;
/**
* The canonical channel taxonomy. One icon and one hue per medium, used
* everywhere a channel is named — table cells, charts, legends, filters, the
* client view. The colour is part of the label, not decoration.
*
* Hue families: blues = large format, violets = moving, ambers = paper,
* roses = direct, slate = digital.
*
* `physical` is a descriptive family property, NOT a weight. The model spec
* (v2026-07-21 §4) assigns each channel its own weight from four factor scores —
* 23 to 50, with persistence separating the tiers. Read weights from
* window.CHANNEL_WEIGHT / MODEL.weights, never from this flag.
*/
declare const CHANNEL_WEIGHTS: Record;
/** Spec §4 weight for a channel name, resolving §9 aliases. 0 = unmodeled (contributes no CRP). */
declare function channelWeightOf(channel: string): number;
declare const ChannelWeightOf: typeof channelWeightOf;
interface ChannelMetaRecord {
label: string;
/** Phosphor icon name, without the `ph-` prefix. */
icon: string;
/** CSS custom-property reference, e.g. "var(--ch-ooh)". */
color: string;
/** Hue family: Large format | Moving | Paper | Direct | Digital | Other. */
family: string;
/** Descriptive family property, not a weight. Channel weights live in MODEL.weights (spec §4). */
physical: boolean;
name?: string;
}
/**
* The canonical channel taxonomy — one icon and one hue per medium. Every screen
* that names a channel reads from this, so a colour always means the same thing.
*/
declare const CHANNELS: Record;
/** Resolve any channel spelling (exact, lowercase key, partial) to its record. */
declare function ChannelMeta(name?: string | null): ChannelMetaRecord;
interface ChannelTagProps extends React.HTMLAttributes {
/** Channel name — "OOH", "Website (CPM)", "transit"… */
channel?: string | null;
size?: "sm" | "md";
/** Hide the text and show icon only (dense tables). */
showLabel?: boolean;
/** Render as a bare colour swatch, for chart legends. */
dot?: boolean;
/** Append the channel's spec §4 weight after the label. */
weight?: boolean;
className?: string;
}
/**
* The standard way to render a channel name. Icon + hue + label, in a tinted
* chip. Use `dot` for a bare colour swatch in dense legends.
*/
declare function ChannelTag({ channel, size, showLabel, dot, weight, className, ...rest }: ChannelTagProps): React.JSX.Element;
/**
* The plan's shape: how funded campuses spread across the four CSI bands.
* Each campus is one labelled block stacked in its band's column, so the
* distribution reads as a silhouette before any number is parsed. Columns are
* clickable to filter the school mix by band.
*/
interface SaturationDistributionCampus {
name: string;
/** Short label shown inside the block, e.g. "Michigan State" */
short?: string;
band: "weak" | "adequate" | "strong" | "dominant";
crp?: number;
}
interface SaturationDistributionProps {
campuses: SaturationDistributionCampus[];
/** Band of the plan-level floor score. Renders the floor explainer strip. */
floorBand?: "weak" | "adequate" | "strong" | "dominant";
/** The plan floor figure, e.g. 157.2 */
floorScore?: number | string;
onSelectBand?: (band: string | null) => void;
selectedBand?: string | null;
loading?: boolean;
className?: string;
}
declare function SaturationDistribution({ campuses, floorBand, floorScore, onSelectBand, selectedBand, loading, className }: SaturationDistributionProps): React.JSX.Element;
/**
* Realized media mix against the strategic target, one row per channel.
* Realized is a solid tone bar, target a striped marker on the same row, and the
* realized share is labelled directly beside them — the gap is the story, so it
* never hides in a legend.
*/
interface MixGapRow {
channel: string;
/** Target share, percent */
target: number;
/** Realized share, percent */
realized: number;
/** Why the gap exists, e.g. "Limited inventory at target campuses" — shown in the hover tooltip. */
note?: string;
}
interface MixGapProps {
rows: MixGapRow[];
loading?: boolean;
className?: string;
}
declare function MixGap({ rows, loading, className }: MixGapProps): React.JSX.Element;
/**
* How one campus's CRP was built, channel by channel, with the surround-sound
* bonus as its own hatched segment. This is the most valuable visualization in
* the planner: it answers why a campus scores what it scores and where the
* headroom is.
*/
interface ChannelContributionChannel {
name: string;
/** CRP contributed by this channel */
crp: number;
/** Formatted spend, e.g. "$12,400" */
spend?: string;
/** Physical formats (OOH, transit, posters) carry roughly 2x the weight of feed-based digital. */
physical?: boolean;
color?: string;
}
interface ChannelContributionProps {
channels: ChannelContributionChannel[];
/** Surround-sound bonus actually earned, as a percent of the channel base. */
bonusPct?: number;
/** The model's ceiling. Defaults to 40. */
bonusMax?: number;
/** Override the computed total when the engine's figure differs from base + bonus. */
total?: number;
loading?: boolean;
showTable?: boolean;
className?: string;
}
declare function ChannelContribution({ channels, bonusPct, bonusMax, total, loading, showTable, className }: ChannelContributionProps): React.JSX.Element;
/**
* The planner's signature interaction: move budget at one campus and watch its
* saturation band re-score live. The previewed band renders at 70% opacity with
* a dashed edge until the change is committed; Escape reverts.
*/
interface BudgetReallocatorProps {
campus: string;
/** Committed spend. */
spend: number;
/** Committed CRP. */
crp: number;
min?: number;
max: number;
/** Defaults to 100 — the same increment as an arrow-key step. */
step?: number;
/** The engine's re-score for a candidate spend. Required for a live preview. */
scoreFor?: (spend: number) => number;
onPreview?: (spend: number, crp: number) => void;
onCommit?: (spend: number, crp: number) => void;
onCancel?: () => void;
className?: string;
}
declare function BudgetReallocator({ campus, spend, crp, min, max, step, scoreFor, onPreview, onCommit, onCancel, className }: BudgetReallocatorProps): React.JSX.Element;
/**
* Channel-diversity breadth at one campus and the bonus it earned or forgave.
* The engine rewards reaching a campus through multiple reinforcing categories
* with up to +40% on its score; thin-breadth campuses forgo most of it.
*/
interface SurroundSoundProps {
/** Category keys present: "ooh" | "transit" | "posters" | "print" | "email" | "web" */
present: string[];
/** Bonus actually earned, in points of percent. */
bonusPct?: number;
/** Model ceiling. Defaults to 40. */
bonusMax?: number;
/** One sentence naming what the missed breadth costs, e.g. "+$3,000 into OOH earns +12%." */
missedCost?: string;
className?: string;
}
declare function SurroundSound({ present, bonusPct, bonusMax, missedCost, className }: SurroundSoundProps): React.JSX.Element;
type ImportanceLevelId = "nudge" | "lean" | "priority" | "driver" | "dealbreaker";
interface ImportanceLevel {
id: ImportanceLevelId;
/** 1-5, ascending. This is the number the --imp-N-* tokens are keyed by. */
rank: 1 | 2 | 3 | 4 | 5;
/** How the level is named in the UI. */
label: string;
/** Phosphor icon name. */
icon: string;
/** One line saying what choosing this level actually does to a plan. */
description: string;
}
/**
* How much a single criterion is allowed to matter, as five ordered steps. Ordered
* ascending, and index-stable: the position in this array IS the `rank`, which is what
* the --imp-N-* colour tokens are keyed by, so reordering it would silently re-colour
* every criterion in the product.
*
* The ramp runs slate → blue → indigo → amber → red, i.e. from "barely registers" to
* "nothing else matters". It deliberately crosses from the neutral/interaction end of
* the palette into the warning end, because that IS the semantic gradient: a
* Deal-breaker isn't a stronger preference, it's a veto, and it should look like one.
*/
declare const IMPORTANCE_LEVELS: ImportanceLevel[];
/** The level record for an id, falling back to the lowest step for an unknown one — an
* unrecognised level should read as "barely matters", never throw in a list row. */
declare function importanceLevel(id: ImportanceLevelId | string): ImportanceLevel;
interface ImportanceTokens {
/** Background wash for a criterion card at this level. */
fill: string;
/** Border for that card. */
edge: string;
/** AA-safe text colour ON `fill`. */
text: string;
/** The solid — meter segments, glyphs, dots. Clears 3:1 on the meter rail. */
mark: string;
}
/**
* The four `var(--imp-N-*)` references for a level, as a plain object, so a criterion
* card elsewhere in a consuming app tints itself from exactly the same tokens the meter
* segments are drawn in rather than from a second hand-copied list of hexes. Returns the
* var() references, not resolved colours — the whole point is that the theme resolves
* them, and every one of these is defined for both light and dark ground.
*/
declare function importanceTokens(id: ImportanceLevelId | string): ImportanceTokens;
interface ImportanceTagProps {
level: ImportanceLevelId | string;
/** Show the level's name next to its glyph. Off for a bare glyph in tight spots. */
showLabel?: boolean;
size?: "sm" | "md";
className?: string;
/** Paint the tag with the kit's own disabled tokens — the same trio `.fd-btn[disabled]`
* uses (`--surface-3`/`--border`/`--text-disabled`) — instead of this level's own
* fill/edge/text, and set `aria-disabled` and the `not-allowed` cursor. Never `opacity`,
* per the kit's 0.41 rule: a disabled control keeps full-strength, AA-safe colour rather
* than fading out. A consumer wrapping the tag in a disabled trigger (e.g. a raw
* ``) should pass this through instead of hand-overriding the level's
* `--imp-{rank}-*` tokens itself. */
disabled?: boolean;
}
/**
* The level itself, as a small tinted tag — glyph plus name in that level's own fill,
* edge and text tokens. Never colour alone: the name and the icon both carry the level,
* so the five steps are still distinguishable without colour vision.
*/
declare function ImportanceTag({ level, showLabel, size, className, disabled }: ImportanceTagProps): React.JSX.Element;
interface PreferenceCriterion {
id: string;
/** What the planner asked for — "Within 50 miles", "Enrollment over 10,000". */
label: string;
importance: ImportanceLevelId | string;
/** The most this criterion could contribute to a 100-point score, i.e. what it is
* worth given its importance relative to the other criteria. */
weight: number;
/** What it actually contributed for this option, 0..weight. A partially-satisfied
* criterion is allowed — this is not a pass/fail flag. */
earned: number;
/** Optional plain-language reason shown under the criterion in the breakdown, e.g.
* "31 miles" or "no outdoor inventory". */
note?: React.ReactNode;
}
/** The score a set of criteria adds up to, rounded to a whole 0-100. Exported because a
* consumer usually needs the same number for sorting the list as for drawing the meter,
* and two implementations of that would drift. */
declare function preferenceScore(criteria: PreferenceCriterion[]): number;
/** One meter segment per criterion, each sized by what that criterion actually earned
* (as a share of 100) and coloured by its importance level's `mark` token. */
declare function preferenceSegments(criteria: PreferenceCriterion[]): MeterSegmentInput[];
interface PreferenceMeterProps {
criteria: PreferenceCriterion[];
/** Overrides the score derived from `criteria`. Pass it when the score is computed
* server-side and the criteria are only the explanation of it. */
score?: number;
/** Accessible name. Defaults to "Preference score". */
label?: string;
width?: number;
height?: number;
showScore?: boolean;
placement?: Placement;
/** Heading shown above the breakdown list. */
breakdownTitle?: string;
className?: string;
}
/**
* PreferenceMeter — how well one option matches the planner's stated priorities, drawn
* as one segment per criterion coloured by that criterion's importance.
*
* The encoding is the point, and it is not "how high did this score". A plain 0-100 bar
* says a school scored 72; this says WHICH of the planner's priorities it satisfies — a
* bar that is mostly red-and-amber is meeting the deal-breakers and drivers, a bar of
* the same length that is mostly slate is scraping the same number out of things the
* planner said barely matter. Two schools with identical scores are visibly different
* propositions, at a glance, in a list row.
*
* Segment width is what the criterion EARNED, not what it was worth, so the unfilled
* remainder of the rail is exactly what the option is failing to deliver. Clicking opens
* the breakdown: every criterion, its level, what it earned out of its weight, and the
* caller's own note explaining why.
*/
declare function PreferenceMeter({ criteria, score, label, width, height, showScore, placement, breakdownTitle, className, }: PreferenceMeterProps): React.JSX.Element;
type ChatStore = {
get(k: string): string | null;
set(k: string, v: string): void;
del(k: string): void;
};
/**
* Chat state machine. Framework-shaped as a React hook, but the rules it encodes
* are the portable part: thread resolution, serial sending, escalation polling,
* recovery, abort semantics and retry truncation.
*
* It ships no HTTP client. Without an adapter it resolves nothing and renders
* nothing — every app's proxy differs in auth, routing and host.
*/
/** Tri-state `valid`: only an explicit false blocks Apply. */
interface Citation {
id?: string;
marker?: string | number;
title?: string;
url?: string;
detail?: string;
}
/** Tri-state `valid`: only an explicit false blocks Apply. */
interface Packet {
type: string;
payload: unknown;
valid?: boolean;
error?: string;
repaired?: boolean;
}
interface PacketSchema {
heading?: string;
icon?: string;
applyLabel?: string;
/** `false` hides the Apply footer for this packet type (a live query card, a CSV). Default: Apply shows. */
apply?: boolean;
}
type PacketDecisionAction = "confirm" | "dismiss";
type PacketDecisionTone = "neutral" | "info" | "success" | "warning" | "danger";
/** What the app says about a packet right now. The design system keeps no state beyond the pending press. */
interface PacketDecisionState {
/** Header badge: waiting, confirming, done, refused, failed, unknown… */
status?: {
label: string;
tone: PacketDecisionTone;
};
/** Once set the packet is decided: replaces the footer. */
outcome?: {
message: React.ReactNode;
tone: "success" | "danger";
};
/** App-reported error banner, shown with both buttons usable. A rejection of `onDecide` shows its own message. */
error?: React.ReactNode;
}
/** Decision mode for one packet type. */
interface PacketDecision {
/** Called with the pressed action; the buttons stay pending until it settles, a rejection shows an error banner. */
onDecide: (action: PacketDecisionAction, packet: Packet) => Promise;
/** Packet's current status, outcome and error. */
state: (packet: Packet) => PacketDecisionState;
confirmLabel?: string;
dismissLabel?: string;
}
interface JobStatus {
id?: string;
/** queued | running keep polling · completed | recovered are success · anything else fails loudly. */
status: string;
detail?: string;
error?: string;
}
interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
text?: string | null;
packet?: Packet | null;
/** Escalation in flight. */
working?: boolean;
/** Tokens arriving — renders a live caret. */
streaming?: boolean;
/** Optimistic, not yet confirmed. */
pending?: boolean;
/** The turn was stopped by the user. */
stopped?: boolean;
jobId?: string;
job?: JobStatus;
error?: string;
retryable?: boolean;
timestamp?: string;
author?: string;
steps?: Step[];
citations?: Citation[];
attachments?: Attachment[];
thinking?: string;
thinkingMs?: number;
feedback?: "up" | "down" | null;
model?: string;
usage?: unknown;
resumed?: boolean;
/**
* This message is a background task that is still in progress (a queued or running query's card). Set by the
* adapter. `working` keeps meaning only "the turn is in flight"; a pending message shows no working row of
* its own. A task-pending message is what a working turn waits on (see `waitingFor`).
*/
taskPending?: boolean;
/**
* What a still-working turn is waiting on, in words ("Running 2 queries"): set by the engine, never the
* adapter, on the working turn whenever messages of the thread are `taskPending`. The working indicator then
* shows this in place of "Working" as the turn's one state. Never an id.
*/
waitingFor?: string;
metadata?: {
type?: string;
[k: string]: unknown;
};
}
interface SendMessageResult {
/** true = escalated to a background job. */
dispatched?: boolean;
job_id?: string;
reply?: string | null;
packet?: Packet;
steps?: Step[];
citations?: Citation[];
usage?: unknown;
model?: string;
}
interface StreamHandlers {
/** Chunks APPEND, never replace. */
onToken(chunk: string): void;
onStep?(step: Step): void;
onThinking?(chunk: string): void;
}
/**
* The app-supplied backend. There is no default: every proxy differs in auth,
* routing and host. Reject with the backend's stable error code as the message —
* the panel matches codes by exact equality, never substring sniffing.
*/
interface ChatAdapter {
resolveThread(ctx: {
contextType: string;
contextId: string;
}): Promise<{
thread_id: string | null;
}>;
getThread(threadId: string): Promise<{
messages: ChatMessage[];
}>;
sendMessage(threadId: string, text: string, signal?: AbortSignal, attachments?: Attachment[]): Promise;
getJob(jobId: string): Promise;
/** Preferred when present. */
streamMessage?(threadId: string, text: string, handlers: StreamHandlers, signal: AbortSignal, attachments?: Attachment[]): Promise;
/** Cancels upstream only. It must not gate whether Stop appears. */
cancelJob?(jobId: string): Promise;
}
interface ChatEngineOptions {
contextType: string;
contextId: string;
apiAdapter?: ChatAdapter | null;
initialMessage?: string | null;
threadId?: string | null;
pollIntervalMs?: number;
/**
* How long a turn may stay queued/running before the panel gives up with a
* retryable "timeout". Default 180000 (3 min). The server's terminal job status
* is always authoritative: a terminal status seen at any time wins over this.
*/
pendingTimeoutMs?: number;
/**
* Words for the background work a working turn is waiting on: called with the thread's `taskPending` messages
* (a running query's card), whenever there is at least one. Default "Waiting on N background
* task(s)". Return the app's own phrasing, e.g. "Running 2 queries".
*/
describeWaiting?: (pending: ChatMessage[]) => string;
store?: ChatStore;
onPacket?: (p: Packet) => void;
onThreadReady?: (id: string) => void;
onError?: (e: unknown) => void;
onSend?: (text: string) => void;
onClear?: () => void;
onFeedback?: (x: {
message?: ChatMessage;
feedback?: string | null;
}) => void;
}
interface QueuedTurn {
id: string;
text: string;
attachments?: Attachment[];
}
interface ChatEngine {
status: "idle" | "resolving" | "loading" | "ready" | "disconnected";
threadId: string | null;
messages: ChatMessage[];
visible: ChatMessage[];
queue: QueuedTurn[];
busy: boolean;
fatal: string | null;
turnStartedAt: number | null;
send(text: string, attachments?: Attachment[]): void;
stop(): void;
retry(): void;
clear(): void;
removeQueued(id: string): void;
setFeedback(id: string, value: "up" | "down"): void;
reload(): Promise;
/** Re-read the thread without touching status; new server items merge in, local-only messages stay. */
refresh(): Promise;
canSend: boolean;
}
declare const CHAT_UNAVAILABLE = "chat_unavailable";
declare const isSystemMessage: (m: ChatMessage | null | undefined) => boolean;
declare const visibleMessages: (list: ChatMessage[] | null | undefined) => ChatMessage[];
declare function jobBucket(status?: string): "pending" | "success" | "failure";
declare function useChatEngine(opts: ChatEngineOptions): ChatEngine;
/**
* Namespace export. The bundle exposes only capital-initial names, so this is how a
* consuming project reaches the state machine to build its own shell:
*
* const { useChatEngine } = window..ChatKit;
* const chat = useChatEngine({ contextType, contextId, apiAdapter });
*/
declare const ChatKit: {
useChatEngine: typeof useChatEngine;
visibleMessages: (list: ChatMessage[] | null | undefined) => ChatMessage[];
isSystemMessage: (m: ChatMessage | null | undefined) => boolean;
jobBucket: typeof jobBucket;
CHAT_UNAVAILABLE: string;
};
interface Suggestion {
id?: string;
label: string;
text?: string;
description?: string;
icon?: string;
}
interface SlashCommand {
id: string;
label: string;
description?: string;
icon?: string;
shortcut?: string; /** Send immediately on pick. */
immediate?: boolean;
run?: () => void;
}
interface MentionSource {
id?: string;
label: string;
value?: string;
description?: string;
icon?: string;
meta?: React.ReactNode;
}
interface Model {
id: string;
label: string;
description?: string; /** Key HINT only. */
shortcut?: string;
group?: string;
meta?: React.ReactNode;
disabled?: boolean;
}
interface ContextUsage {
total: number;
segments: MeterSegmentInput[];
label?: string;
format?: "count" | "compact" | "bytes";
}
interface UsageLimit {
id: string;
label: string;
percent: number;
resetsAt?: string;
note?: string;
}
interface SessionStats {
elapsedMs?: number;
tokens?: number;
runningTasks?: number;
costUsd?: number;
turns?: number;
}
interface ThreadSummary {
id: string;
title?: string;
description?: string;
updatedAt?: string;
}
type VoiceHandler = (controls: {
onText: (text: string, meta?: {
replace?: boolean;
}) => void;
onEnd?: () => void;
}) => (() => void) | void;
interface AgentChatPanelProps {
contextType: string;
contextId: string;
/** Whose draft this is: the signed-in user's id. The composer's unsent text is kept in the tab's session storage
* under this owner, so it survives the panel being remounted but is never shown to a different owner. `null` =
* no owner to scope to, so the draft is not stored at all. */
draftOwner: string | null;
apiAdapter: ChatAdapter | null;
/** Show this exact thread instead of resolving one from context. Set it from
* onNewThread / onSelectThread to make history switching work. */
threadId?: string | null;
/** Change it to make the panel re-read its thread, e.g. after the app adds an item to the conversation outside a chat turn. Not read on first mount. The transcript stays, a live turn is untouched. */
reloadKey?: string | number;
title?: string;
subtitle?: React.ReactNode;
showHeader?: boolean;
headerActions?: React.ReactNode;
onClose?: () => void;
assistantName?: string;
userName?: string;
assistantAvatar?: string;
userAvatar?: string;
/** Phosphor name for the assistant mark. Default "sparkle". */
assistantIcon?: string;
showAvatars?: boolean;
markdown?: boolean;
/** Clamp threshold before Show more. Default 600; 0 disables. */
maxVisibleChars?: number;
messageActions?: boolean;
showSteps?: boolean;
showThinking?: boolean;
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: string;
suggestions?: Array;
renderEmpty?: () => React.ReactNode;
packetSchemas?: Record;
packetRenderers?: Record React.ReactNode>;
onApplyPacket?: (p: Packet) => void;
/** Decision mode per packet type: header status badge, pinned Confirm/Dismiss footer, then the outcome. */
packetDecisions?: Record;
/** Give-up time for a queued/running turn, in ms. Default 180000. The server's terminal job status always wins. */
pendingTimeoutMs?: number;
/** Words for the background work a live turn waits on, given the still-working cards ("Running 2 queries"). */
describeWaiting?: (pending: ChatMessage[]) => string;
onOpenAttachment?: (a: Attachment) => void;
/** Map a backend error code to human copy. */
errorCopy?: (code: string) => React.ReactNode;
placeholder?: string;
maxLength?: number;
hint?: React.ReactNode;
/** Omit and there is no attach affordance — a pasted image falls through. */
fileUploadHandler?: FileUploadHandler;
acceptFiles?: string;
maxFileSize?: number;
/** Chars above which a paste becomes a .txt attachment. Default 4000. */
largePasteThreshold?: number;
slashCommands?: SlashCommand[];
mentionSources?: MentionSource[] | ((query: string) => Promise);
voiceHandler?: VoiceHandler;
initialMessage?: string | null;
contextUsage?: ContextUsage;
sessionStats?: SessionStats;
usageLimits?: UsageLimit[];
models?: Model[];
moreModels?: Model[];
model?: string;
onModelChange?: (id: string) => void;
effortLevels?: string[];
effort?: string;
onEffortChange?: (level: string) => void;
fastMode?: boolean;
onFastModeChange?: (on: boolean) => void;
threads?: ThreadSummary[];
onSelectThread?: (t: ThreadSummary) => void;
onNewThread?: () => void;
/** Supply to put a delete affordance on each history row. Omit and none renders. */
onDeleteThread?: (t: ThreadSummary) => void;
surface?: "sidebar" | "inline" | "page" | "modal" | "sheet";
height?: number | string;
className?: string;
style?: React.CSSProperties;
onPacket?: (p: Packet) => void;
onThreadReady?: (id: string) => void;
onError?: (e: unknown) => void;
onSend?: (text: string) => void;
onClear?: () => void;
onFeedback?: (x: {
message?: ChatMessage;
feedback?: string | null;
}) => void;
onEditMessage?: (m: ChatMessage) => void;
}
/**
* Agent chat panel.
*
* Ships no backend: without `apiAdapter` it loads nothing and says so. Every
* optional surface — usage meters, model picker, attachments, avatars, steps —
* renders only when its input is supplied. A panel with no telemetry shows no
* telemetry, not zeroes.
*/
declare function AgentChatPanel({ contextType, contextId, draftOwner, apiAdapter, threadId, reloadKey, title, subtitle, showHeader, headerActions, onClose, assistantName, userName, assistantAvatar, userAvatar, assistantIcon, showAvatars, markdown, maxVisibleChars, messageActions, showSteps, showThinking, emptyTitle, emptyDescription, emptyIcon, suggestions, renderEmpty, packetSchemas, packetRenderers, packetDecisions, onApplyPacket, pendingTimeoutMs, describeWaiting, onOpenAttachment, errorCopy, placeholder, maxLength, hint, fileUploadHandler, acceptFiles, maxFileSize, largePasteThreshold, slashCommands, mentionSources, voiceHandler, initialMessage, contextUsage, sessionStats, usageLimits, models, model, onModelChange, effortLevels, effort, onEffortChange, fastMode, onFastModeChange, moreModels, threads, onSelectThread, onNewThread, onDeleteThread, surface, height, className, style, onPacket, onThreadReady, onError, onSend, onClear, onFeedback, onEditMessage, }: AgentChatPanelProps): React.JSX.Element;
/**
* Staged files, the queue strip, the markdown editor, slash/mention triggers, send + stop.
* Enter sends, Shift+Enter continues the list or quote you are in, IME composition never
* sends. Omit `fileUploadHandler` and there is no attach affordance at all — a pasted
* image then falls through to the editor rather than stranding on a message that cannot
* carry it.
*/
interface ChatComposerProps {
onSubmit: (text: string, attachments?: Attachment[]) => void;
/** Offered whenever a turn is in flight, even with no upstream cancel. */
onStop?: () => void;
busy?: boolean;
disabled?: boolean;
placeholder?: string;
maxLength?: number;
fileUploadHandler?: FileUploadHandler;
acceptFiles?: string;
maxFileSize?: number;
/** Chars above which a paste becomes a .txt attachment. Default 4000. */
largePasteThreshold?: number;
slashCommands?: SlashCommand[];
mentionSources?: MentionSource[] | ((query: string) => Promise);
voiceHandler?: VoiceHandler;
/** Messages waiting behind the turn in flight. Each row is removable. */
queue?: Array<{
id: string;
text: string;
attachments?: Attachment[];
}>;
onRemoveQueued?: (id: string) => void;
/** Rendered above the field — where AgentChatPanel puts the session bar. */
sessionBar?: React.ReactNode;
toolbarExtras?: React.ReactNode;
/** Controlled draft, so a host can prefill (edit-a-message does this). */
draft?: string;
onDraftChange?: (text: string) => void;
hint?: React.ReactNode;
narrow?: boolean;
autoFocus?: boolean;
onReject?: (rejections: FileRejection[]) => void;
onOpenAttachment?: (a: Attachment) => void;
}
/**
* The composer: staged attachments, the visible send queue, the editor, and the
* send/stop control. Every optional affordance appears only when its input is
* supplied — no upload handler means no attach button at all.
*/
declare function ChatComposer({ onSubmit, onStop, busy, disabled, placeholder, maxLength, fileUploadHandler, acceptFiles, maxFileSize, largePasteThreshold, slashCommands, mentionSources, voiceHandler, queue, onRemoveQueued, sessionBar, toolbarExtras, draft, onDraftChange, hint, narrow, autoFocus, onReject, onOpenAttachment, }: ChatComposerProps): React.JSX.Element;
/**
* Elapsed time, token spend, context window and quota rows. Renders `null` unless at
* least one input is supplied — the bar is never an empty frame.
*/
interface ChatSessionBarProps {
sessionStats?: SessionStats;
contextUsage?: ContextUsage;
usageLimits?: UsageLimit[];
onClear?: () => void;
extras?: React.ReactNode;
}
/**
* The strip above the composer. Every part is prop-fed: supply none of
* sessionStats / contextUsage / usageLimits and no bar renders at all.
*/
declare function ChatSessionBar({ sessionStats, contextUsage, usageLimits, onClear, extras }: ChatSessionBarProps): React.JSX.Element | null;
/**
* Model picker, effort level and fast mode. `null` when no models are given.
* A model's `shortcut` renders a key cap and binds nothing.
*/
interface ModelControlsProps {
models?: Model[];
model?: string;
onModelChange?: (id: string) => void;
effortLevels?: string[];
effort?: string;
onEffortChange?: (level: string) => void;
fastMode?: boolean;
onFastModeChange?: (on: boolean) => void;
fastModeLabel?: string;
/** Collapsed into a submenu. */
moreModels?: Model[];
narrow?: boolean;
}
/**
* Model picker (+ optional effort and fast-mode). Renders only when `models` is
* supplied. Shortcuts are hints; the app registers the real keys.
*/
declare function ModelControls({ models, model, onModelChange, effortLevels, effort, onEffortChange, fastMode, onFastModeChange, fastModeLabel, moreModels, narrow: _narrow, }: ModelControlsProps): React.JSX.Element | null;
/**
* Render context, built once by AgentChatPanel and threaded into every part.
* Assembling your own shell means supplying this yourself.
*/
interface ChatRenderContext {
assistantName?: string;
userName?: string;
assistantAvatar?: string;
userAvatar?: string;
/** Phosphor name for the assistant mark. Default "sparkle". */
assistantIcon?: string;
showAvatars?: boolean;
markdown?: boolean;
/** Clamp threshold before Show more. 0 disables. */
maxVisibleChars?: number;
messageActions?: boolean;
showSteps?: boolean;
showThinking?: boolean;
packetSchemas?: Record;
packetRenderers?: Record React.ReactNode>;
appliedPackets?: Record;
/** Decision mode per packet type (see PacketCard `decision`). */
packetDecisions?: Record;
/** Sidebar-width layout: tighter padding, stacked controls. */
narrow?: boolean;
onOpenAttachment?: (a: Attachment) => void;
/** Max height (px) of the attachment grid before it scrolls. Default 220. */
attachmentHeight?: number;
/** Renderer for `[^n]` citation markers inside markdown bodies. */
renderCitation?: (marker: string) => React.ReactNode;
/** Map a backend error code to human copy. */
errorCopy?: (code: string) => React.ReactNode;
onRetry?: () => void;
onFeedback?: (id: string, value: "up" | "down") => void;
onEdit?: (m: ChatMessage) => void;
onApplyPacket?: (p: Packet) => void;
onOpenCitation?: (c: Citation, e: React.MouseEvent) => void;
extraActions?: (group: MessageGroup) => React.ReactNode;
/** Disconnected-state heading. Default "The assistant isn't reachable". */
deadTitle?: string;
onReconnect?: () => void;
}
/** A run of consecutive messages from one sender — see groupMessages. */
interface MessageGroup {
role: string;
author?: string;
messages: ChatMessage[];
/** A single-message run; affects spacing and the author label. */
solo: boolean;
key: string;
}
/** A structured result. `valid: false` blocks Apply; undefined does not. */
interface PacketCardProps {
packet: Packet;
schema?: PacketSchema;
render?: (p: Packet) => React.ReactNode;
onApply?: (p: Packet) => void;
applied?: boolean;
/** Decision mode: a status badge, a pinned Confirm/Dismiss footer, then the outcome. Replaces the Apply footer. */
decision?: PacketDecision;
}
/**
* A structured result carried by a message. `valid` is tri-state: only an
* explicit `false` blocks Apply — an unvalidated packet is a legitimate result.
*/
declare function PacketCard({ packet, schema, render, onApply, applied, decision }: PacketCardProps): React.JSX.Element | null;
interface ThinkingBlockProps {
text?: string;
durationMs?: number;
streaming?: boolean;
defaultOpen?: boolean;
}
declare function ThinkingBlock({ text, durationMs, streaming, defaultOpen }: ThinkingBlockProps): React.JSX.Element | null;
interface CitationsProps {
items?: Citation[];
onOpen?: (c: Citation, e: React.MouseEvent) => void;
}
declare function Citations({ items, onOpen }: CitationsProps): React.JSX.Element | null;
/** Message content only — markdown, code blocks, clamping. No chrome. */
interface MessageBodyProps {
message: ChatMessage;
ctx: ChatRenderContext;
}
declare function MessageBody({ message: m, ctx }: MessageBodyProps): React.JSX.Element;
/** Copy / retry / feedback / edit. Real buttons; the hover reveal is CSS only. */
interface RunActionsProps {
group: MessageGroup;
ctx: ChatRenderContext;
}
/**
* One toolbar per run — copying the whole run, not one message. Buttons stay in
* the DOM (the reveal is CSS only), so keyboard and screen-reader users, who
* never produce a hover, can still reach them.
*/
declare function RunActions({ group, ctx }: RunActionsProps): React.JSX.Element | null;
/** One turn: author label, body, attachments, packet, thinking, citations, actions. */
interface ChatTurnProps {
group: MessageGroup;
ctx: ChatRenderContext;
}
declare function ChatTurn({ group, ctx }: ChatTurnProps): React.JSX.Element;
/** Consecutive messages from one sender inside 60s share a head. Anything
* carrying a packet, steps or an error always starts its own group. */
declare function groupMessages(list: ChatMessage[]): MessageGroup[];
/**
* Runs of consecutive messages from one sender, for run-vs-turn spacing.
* `role="log"` and deliberately NOT also aria-live — a live region on a
* transcript makes a screen reader announce every token of a streaming reply.
*/
interface ChatTranscriptProps {
messages?: ChatMessage[];
ctx: ChatRenderContext;
status?: "idle" | "resolving" | "loading" | "ready" | "disconnected";
/** Terminal error code; renders the disconnected state. */
fatal?: string | null;
suggestions?: Array;
onPick?: (s: Suggestion | string) => void;
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: string;
renderEmpty?: () => React.ReactNode;
className?: string;
}
/**
* The transcript: a role="log" region (and deliberately not aria-live too —
* that doubles announcements). Grouping, day dividers, and an auto-scroll that
* follows only while the reader is already at the bottom.
*/
declare function ChatTranscript({ messages, ctx, status, fatal, suggestions, onPick, emptyTitle, emptyDescription, emptyIcon, renderEmpty, className, }: ChatTranscriptProps): React.JSX.Element;
/** Namespace export — grouping helper, for consumers rendering their own transcript. */
declare const TranscriptKit: {
groupMessages: typeof groupMessages;
};
type QueryResultStatus = "queued" | "running" | "done" | "failed" | "canceled";
declare const QUERY_EMPTY_MESSAGE = "No rows to show.";
interface QueryResultCardProps {
title: string;
status: QueryResultStatus;
/** ISO time the query started; a running query shows how long it has run, on a local clock ticking each second. */
startedAt?: string | null;
failureMessage?: string | null;
/** Headline figures, already formatted. */
facts?: Fact[];
/** The preview's columns and rows. */
columns?: DataTableColumn[];
rows?: object[];
/** The row field that identifies a row. */
rowKey?: string;
/** The card's own actions (an export button, say): rendered as given, so the card names no action of any app. */
actions?: React.ReactNode;
}
/**
* The live card of a background query an agent started, inside `PacketCard` (`AgentChatPanel`'s `packetRenderers`, with
* `apply: false`: a query is never applied). The panel re-reads the thread on every poll while a turn runs, so the card
* moves from queued to running to done or failed by itself. Presentational: pass the packet's fields.
*/
declare function QueryResultCard({ title, status, startedAt, failureMessage, facts, columns, rows, rowKey, actions, }: QueryResultCardProps): React.JSX.Element;
/** Where a background export is. */
type ExportJobStatus = "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED";
/** One background export, in the shape an app maps its own export rows to. The one shape the tray, the CSV card and the polling hook
* all read, so a chat card and the tray never show two snapshots of the same export. */
interface ExportJob {
id: string;
/** What the export was made from: the tray's card title. */
title: string;
/** What kind of export it is: the tray's card note. */
note?: string;
/** The name the downloaded file carries. */
fileName: string;
status: ExportJobStatus;
rowCount: number | null;
/** Set only once SUCCEEDED: when the download stops being available. */
expiresAt: string | null;
failureMessage: string | null;
}
declare function isLiveExport(job: ExportJob): boolean;
declare function isExpiredExport(job: ExportJob, now: number): boolean;
declare const CSV_FAILED_FALLBACK = "The CSV could not be made.";
declare const CSV_KEPT_NOTE = "Kept 24 hours";
interface CsvCardProps {
/** The export as the card should show it. */
job: ExportJob;
/** Takes the file. Download is on only while the export is ready; the button shows busy until this settles. */
onDownload: (id: string) => Promise;
}
/**
* The live card of a CSV an agent made (or the person made from a query card), for `AgentChatPanel`'s `packetRenderers`
* with `apply: false`: a file is downloaded, never applied. Presentational: the export's state is the `job` it is given,
* and a ready file turns expired by itself at the moment it expires while the card is open.
*/
declare function CsvCard({ job, onDownload }: CsvCardProps): React.JSX.Element;
/** Kept modest on purpose: this is a PREVIEW, not a document viewer. A file past this
* many characters still opens — it just shows its first slice, same as CodeBlock's own
* line fold for a long file. */
declare const FILE_VIEWER_TEXT_LIMIT = 500000;
/** CSV/TSV render as a real table, which means real DOM rows — bounded for the same
* reason a virtualized list is, just simpler: this is a preview, and a spreadsheet with
* 50,000 rows is what the "download" action is for. */
declare const FILE_VIEWER_CSV_ROW_LIMIT = 500;
/**
* A minimal, dependency-free delimited-text reader: quoted fields, `""` as an escaped
* quote inside one, and either line ending. Not a full RFC 4180 implementation — good
* enough for a bounded preview table, which is all this ever renders.
*/
declare function parseDelimited(text: string, delimiter: string): string[][];
interface FileViewerProps {
file: Attachment;
/**
* Mints a short-lived URL for THIS file on demand — called once for the inline
* preview and again, separately, for a download. The component never reads
* `file.url` itself: production URLs are signed and expire in minutes, so the
* only correct URL is one just resolved for the disposition in hand.
*/
resolveUrl: (disposition: "inline" | "attachment") => Promise;
/**
* A companion panel — notes, metadata, associations, whatever the host wants
* beside the file. Generic on purpose: FileViewer has no idea what a "note" or
* a "campaign" is. Beside the preview when there's room, a tab next to Preview
* when there isn't. Omit it and there is no side panel and no tab bar at all.
*/
sidePanel?: React.ReactNode;
/** Tab label / region name for `sidePanel`. Default "Details". */
sidePanelLabel?: string;
onClose?: () => void;
/** Extra controls beside the built-in Download action, e.g. a host's "Save to campaign". */
actions?: React.ReactNode;
className?: string;
}
/**
* A file preview with an optional companion side panel: image, PDF, text/markdown/JSON,
* a bounded CSV/TSV table, or an honest "no preview" fallback with the file's own
* metadata — plus download, close and a host action slot. Fully responsive: side by side
* with `sidePanel` while there's room, a tab next to Preview below a narrow width of its
* OWN container (a `@container` query, not the viewport — see the stylesheet), with
* exactly one scroll region per pane either way. Renders inside the kit's `Modal` or
* `Drawer`, or inline in a page — it has no opinion on how it got on screen.
*/
declare function FileViewer({ file, resolveUrl, sidePanel, sidePanelLabel, onClose, actions, className }: FileViewerProps): React.JSX.Element;
/** A folder in the tree — an adjacency list, the same shape a real directory store
* needs (see MP-128): `parentId: null` is a root-level folder. Generic on purpose,
* no campaign/thread vocabulary — the host's ids are whatever it wants them to be. */
interface FileBrowserFolder {
id: string;
parentId: string | null;
name: string;
}
/** The host's own file shape, filed into exactly one folder (`folderId: null` = root). */
type FileBrowserFile = Attachment & {
folderId: string | null;
};
/** Every affordance defaults to visible when its matching callback is supplied — set a
* flag to `false` to withhold that ONE affordance for a caller that has the callback
* wired but the current viewer lacks the right (e.g. a read-only campaign guest gets
* `onOpenFile` but not `canMove`/`canRemove`). Omitting the whole object shows
* everything a supplied callback affords. */
interface FileBrowserPermissions {
canCreateFolder?: boolean;
canRenameFolder?: boolean;
canMove?: boolean;
canUpload?: boolean;
canRemoveFile?: boolean;
canDeleteFolder?: boolean;
}
interface FileBrowserProps {
folders: FileBrowserFolder[];
files: FileBrowserFile[];
/** `null` = the root folder. */
currentFolderId: string | null;
onNavigate: (folderId: string | null) => void;
onCreateFolder?: (parentId: string | null, name: string) => void;
onRenameFolder?: (folderId: string, name: string) => void;
onMoveFolder?: (folderId: string, targetParentId: string | null) => void;
onMoveFile?: (fileId: string, targetFolderId: string | null) => void;
onOpenFile?: (file: FileBrowserFile) => void;
onRemoveFile?: (file: FileBrowserFile) => void;
/** MP-190: same contract as `onRenameFolder`/`onMoveFolder` — no fetching, no emptiness
* pre-check (the host's server is the one source of truth for "empty"; see the folder
* kit doc comment above), fired only once the confirm dialog is accepted. Sibling
* callbacks (`onRenameFolder`, `onMoveFolder`, `onRemoveFile`) are all plain, synchronous,
* void callbacks with no promise/rejection handling anywhere in this component, so this
* one is void too rather than inventing an async contract none of its siblings have — a
* host that needs to show the server's `folder_not_empty` refusal does so the same way it
* already must for a failed rename/move/remove: by feeding the error back through its own
* state, outside this component. */
onDeleteFolder?: (folder: FileBrowserFolder) => void;
/** Fired with the raw picked/dropped files and the folder they landed in — FileBrowser
* does no fetching and stages nothing itself (R-8/"generic, controlled" — see MP-135):
* the host runs its own `useStagedFiles`/`FileUploadHandler` and feeds the result back
* through `files`, exactly like every other controlled list in this kit. */
onUpload?: (files: File[], folderId: string | null) => void;
loading?: boolean;
permissions?: FileBrowserPermissions;
className?: string;
}
/**
* FileBrowser — a folder tree plus the current folder's contents: create, rename and move,
* by drag or by an equally real keyboard/click menu, over a responsive two-pane layout.
*
* Generic and fully controlled (MP-135, phase 7 of MP-128): no data fetching, no upload,
* no policy of any kind (no size cap, no type allow-list, no retention rule) — every one of
* those is a product decision the epic has explicitly NOT made yet (see MP-128's open
* problem), so this component encodes none of them. It only ever renders the `folders`/
* `files` it is handed and reports intent through callbacks; the host owns every mutation,
* every permission check and every byte that moves.
*
* Two panes side by side while there's room — the folder tree (search this file's own
* width, a container query, exactly like FileViewer's side panel: a FileBrowser opened
* inside a narrow Drawer should behave like a phone even on a wide screen) and the current
* folder's contents. Below that width the tree pane disappears entirely (`display: none`,
* not a tab — there is nothing to switch to, since the current folder's own subfolders
* already appear as rows in the main pane) and navigation happens purely through the
* breadcrumb trail plus tapping into a subfolder row, per the card's own spec. One scroll
* region per pane either way (R-8): the tree pane and the main pane each own their own
* `overflow`, the head/breadcrumb bar never scrolls.
*/
declare function FileBrowser({ folders, files, currentFolderId, onNavigate, onCreateFolder, onRenameFolder, onMoveFolder, onMoveFile, onOpenFile, onRemoveFile, onDeleteFolder, onUpload, loading, permissions, className, }: FileBrowserProps): React.JSX.Element;
/** One row of the apps feed. `url`/`icon` are nullable because the identity provider is not
* seeded with both for every app. */
interface SessionAppFeedEntry {
key: string;
name: string;
url: string | null;
icon: string | null;
}
/** The signed-in person as the identity provider reports them. */
interface SessionAdapterUser {
id: string;
name: string;
email: string;
roles: {
key: string;
name: string;
}[];
permissions: string[];
}
/** A usable session: live (`authenticated`) or an identity-provider/auth-store outage that
* still has a last-known user (`unavailable`). */
interface AdaptableSession {
status: "authenticated" | "unavailable";
user: SessionAdapterUser;
apps: SessionAppFeedEntry[];
signOut: () => void | Promise;
}
/**
* Adapts the apps feed into `AppSwitcher` entries and marks the one whose key is
* `currentAppKey`. An app with no `url` is dropped rather than rendered as a dead `#` link.
*/
declare function toSwitcherEntries(apps: SessionAppFeedEntry[], currentAppKey: string): AppEntry[];
/**
* Adapts a session into `SessionContextValue`, memoized on the session's own fields.
* `roles[].id` and `hasRole` both key on the role's `key`, its stable slug.
*
* During an outage (`status === "unavailable"`) the last-known user is still shown but the
* cached permissions are display-only: `can`, `canAny` and `hasRole` refuse everything, and
* `pending` is true so `Gate` renders its neutral pending state instead of "access denied".
*/
declare function useSessionContextValue(session: AdaptableSession, { currentAppKey }: {
currentAppKey: string;
}): SessionContextValue;
interface PermissionItem {
key: string;
label: string;
detail: string;
}
interface PermissionGroup {
group: string;
items: PermissionItem[];
}
interface Role {
id: string;
name: string;
system: boolean;
description: string;
permissions: string[];
}
interface Flag {
key: string;
project: string;
label: string;
description: string;
/** Kill switch. False means off for everyone, whatever the targeting says. */
enabled: boolean;
/** 0-100. Bucketed on a stable hash of the user id so answers never flicker. */
rollout: number;
/** Role ids the flag is forced on for. */
roles: string[];
/** Per-user overrides, highest precedence: { userId: true | false } */
users: Record;
owner?: string;
updated?: string;
/** Which screen this flag gates, if any. */
screen?: string;
/** Target ship date for an unshipped feature. */
eta?: string | null;
/** Date this shipped — set instead of `eta` once it's done. */
shippedAt?: string;
/** Build phase index into PHASES. */
phase?: number;
/** Human estimate, e.g. "4 days". */
effort?: string;
/** What has to be built for this to be real. */
backend?: string;
/** Other flag keys this depends on. */
dependsOn?: string[];
}
/** The demo fixture's user record. Extends the provider-agnostic `SessionUser` (see
* `kits/sessionContext.tsx`) with bookkeeping fields only this in-memory fixture needs — a
* real, injected session is never expected to supply `status`/`lastActive`. */
interface User extends SessionUser {
roles: string[];
status: "active" | "invited" | "suspended";
lastActive?: string;
}
interface Session {
user: User;
roles: Role[];
permissions: string[];
/** Resolved flag key → on/off for THIS user. */
flags: Record;
allRoles: Role[];
allFlags: Flag[];
allUsers: User[];
}
interface FlagExplanation {
on: boolean;
reason: string;
detail: string;
}
/** roadmap()'s per-flag result row. Not declared in session.d.ts (roadmap() itself is one of
* the six SessionKit members missing from that file) — inferred from what roadmap() actually
* builds and returns for each flag. */
interface RoadmapItem {
key: string;
label: string;
project: string;
description: string;
backend?: string;
effort?: string;
owner?: string;
dependsOn: string[];
screen: string | null;
enabled: boolean;
/** Which build phase this sits in, and why that phase comes where it does. */
phase?: number;
phaseName: string;
phaseWhy: string | null;
phaseStart: string | null;
/** Derived, not stored: whether every endpoint the feature needs is wired. */
implemented: boolean;
date: string | null;
/** True when the only date on record is when the UI was designed, not when it ships. */
dateIsDesign: boolean;
status: "shipped" | "next" | "planned";
}
/** roadmap()'s return shape. */
interface RoadmapResult {
items: RoadmapItem[];
/** Index of the first unshipped item — everything above it is history. */
nextIndex: number;
shipped: number;
remaining: number;
}
/** Stable 0–99 bucket from a string, so rollout answers never flicker between reloads. */
declare function bucket(seed: string): number;
/** Build the session document a real GET /me would return. */
declare function buildSession(userId: string, overrides?: {
users?: User[];
roles?: Role[];
flags?: Flag[];
}): Session;
/** The precedence rule, in one place so the admin UI and the app can never disagree. */
declare function evaluateFlag(flag: Flag, user: User): boolean;
/** Why a flag resolved the way it did — shown in the admin UI, invaluable in support. */
declare function explainFlag(flag: Flag, user: User): FlagExplanation;
declare global {
interface Window {
/** Admin-app-injected overrides for the demo dataset, read by loadSession(). */
__FD_ADMIN_OVERRIDES?: {
users?: User[];
roles?: Role[];
flags?: Flag[];
};
}
}
/** Replace this with `fetch(API + "/me")` to go live. */
declare function loadSession(userId?: string): Session;
declare function getSession(): Session;
declare function setActiveUser(userId: string): Session;
/** Local profile edits persist so the demo keeps them across reloads. */
declare function saveProfile(patch: Partial): User;
/** Recompute after the admin app mutates users/roles/flags. */
declare function refresh(): Session;
declare function subscribe(fn: (s: Session) => void): () => void;
declare function can(permission: string | string[]): boolean;
declare function canAny(permissions: string[]): boolean;
declare function hasRole(roleId: string): boolean;
declare function flagOn(key: string): boolean;
/** The flag registry entry, whatever the session shape.
* Not declared in session.d.ts — see the module doc comment on SessionKit below. */
declare function findFlag(key: string): Flag | null;
declare function flagState(key: string): "on" | "off";
declare function isEnabled(key: string): boolean;
declare function flagVisible(key: string): boolean;
declare function flagLive(key: string): boolean;
declare function roadmap(overrides?: {
flags?: Flag[];
isComplete?: (key: string) => boolean;
}): RoadmapResult;
/** React binding. Re-renders on user switch, profile save, or admin mutation. */
declare function useSession(): Session;
/**
* Session, RBAC and feature flags for every flytedesk app.
*
* Not a security boundary: permissions gate the UI so people aren't shown affordances they
* cannot use. The server must re-check all of it.
*
* To go live, replace loadSession() with a fetch of GET /me → { user, roles, permissions, flags }.
*/
declare const SessionKit: {
PERMISSION_CATALOG: PermissionGroup[];
ALL_PERMISSIONS: string[];
ROLES: Role[];
FLAGS: Flag[];
USERS: User[];
loadSession: typeof loadSession;
getSession: typeof getSession;
setActiveUser: typeof setActiveUser;
saveProfile: typeof saveProfile;
refresh: typeof refresh;
subscribe: typeof subscribe;
can: typeof can;
canAny: typeof canAny;
hasRole: typeof hasRole;
flagOn: typeof flagOn;
useSession: typeof useSession;
findFlag: typeof findFlag;
flagState: typeof flagState;
isEnabled: typeof isEnabled;
flagVisible: typeof flagVisible;
flagLive: typeof flagLive;
roadmap: typeof roadmap;
evaluateFlag: typeof evaluateFlag;
explainFlag: typeof explainFlag;
bucket: typeof bucket;
buildSession: typeof buildSession;
};
/**
* Mounts the built-in fixture `SessionKit` behind the same `SessionProvider` contract every
* session-aware component reads (see `kits/sessionContext.tsx`). This is the ONLY place the
* demo fixture is wired to that contract — AccountMenu/Gate/ProfilePage/AppSwitcher never
* import `SessionKit` directly, so a real app supplying its own ``
* gets identical behavior with no demo-only code path left running underneath it.
*
* `demo` is populated on the resulting context value, which is what lets AccountMenu show
* "View as another member" here and nowhere else: a real session simply never sets it.
*/
declare function DemoSessionProvider({ children }: {
children?: React.ReactNode;
}): React.FunctionComponentElement;
/** Where the params live. The default is the browser's address; an app with a router
* passes an adapter over the router's search params so the router stays in step. */
interface QueryUrlAdapter {
read(): URLSearchParams;
/** Replaces the current history entry's params: a refinement of one list is not a place to go Back to. */
write(next: URLSearchParams): void;
/** Calls `onChange` with the params as they now are whenever they change (Back, Forward, or a
* `write`), and returns the unsubscribe. The params are passed, never re-read through the adapter,
* because an adapter built per render answers `read()` with the params of the render that built it
* and may notify before React renders again. A notification of the list's own `write` carries the
* params it wrote, which the hook recognises as no change. `read` is called once, when the list mounts. */
subscribe(onChange: (params: URLSearchParams) => void): () => void;
}
interface QueryUrlOptions {
/** Prefix for this list's params, for a screen with more than one list. */
scope?: string;
/** Defaults to the browser's address (`historyUrlAdapter`). */
adapter?: QueryUrlAdapter;
}
type QueryPaging = "page" | "keyset";
declare const historyUrlAdapter: QueryUrlAdapter;
/** The query the address describes: `base` (the options' defaults) for whatever it does not name. */
declare function readQueryFromUrl(params: URLSearchParams, scope: string | undefined, base: TableQuery, paging: QueryPaging): TableQuery;
/** A copy of `params` whose own keys describe `query`: a value equal to `base` is removed, not written. */
declare function writeQueryToUrl(params: URLSearchParams, scope: string | undefined, query: TableQuery, base: TableQuery, paging: QueryPaging): URLSearchParams;
interface TableQuery {
/** 1-indexed. */
page: number;
pageSize: number;
sort: string | null;
dir: "asc" | "desc";
/** Free-text search across the endpoint's searchable columns. */
q: string;
filters: Record;
}
interface Pager {
page: number;
pageSize: number;
total: number;
totalPages: number;
from: number;
to: number;
hasPrev: boolean;
hasNext: boolean;
}
/** The envelope EVERY paged list endpoint returns. */
interface ListEnvelope {
rows: Row[];
pager: Pager;
sort: {
key: string;
dir: "asc" | "desc";
} | null;
filter: {
applied: Record;
q: string;
facets: Record>;
};
aggregate?: Record;
}
/** The envelope a keyset-paged list endpoint returns (`paging: "keyset"`): one page of rows and the
* cursor of the next, which is how a list that cannot cheaply count its rows (a BigQuery read) pages. */
interface KeysetEnvelope {
rows: Row[];
/** Opaque; null on the last page. Sent back as `after` to read the page that follows. */
next: string | null;
/** Every row of the match, when the endpoint can say. */
total?: number;
filter?: {
facets: Record>;
};
aggregate?: Record;
}
interface RunConfig {
searchKeys?: string[];
filters?: Record boolean>;
/** Sort value for a column whose key isn't a plain field. */
accessors?: Record unknown>;
/** Option counts, computed over the SEARCHED set so a filter never erases its own alternatives. */
facets?: Record string | number | null>;
defaultSort?: {
key: string;
dir: "asc" | "desc";
};
defaultPageSize?: number;
}
/** What a table has whichever way its endpoint pages. */
interface ServerTableBase {
rows: Row[];
pageSize: number;
facets: Record>;
aggregate: Record;
query: TableQuery;
sort?: {
key: string;
dir: "asc" | "desc";
};
q: string;
filters: Record;
loading: boolean;
/** True only on the FIRST load — later loads keep showing stale rows. */
initialLoading: boolean;
/** True while re-querying with previous rows still on screen. Pass straight to
* DataTable's `refreshing`, or to LoadingRegion as state="refreshing". */
refreshing: boolean;
/** Why the last request failed; null while there is nothing to show. A first load that fails
* leaves `rows` empty; a refresh or a `loadMore` that fails keeps the rows already on screen.
* Cleared by the next request, whether `retry`, `refresh` or a change of query. */
error: Error | null;
/** Runs again what failed: the query, or the page `loadMore` was reading. */
retry(): void;
activeFilterCount: number;
setPageSize(n: number): void;
setQ(q: string): void;
toggleSort(key: string): void;
setSortKey(key: string, dir?: "asc" | "desc"): void;
/** value may be an updater fn — REQUIRED for toggles, or two fast clicks read the same
* stale array and the first is lost. */
setFilter(id: string, value: unknown | ((prev: unknown) => unknown)): void;
clearFilter(id: string): void;
setFilters(filters: Record): void;
clearFilters(): void;
/** Re-queries now, even when the query has not changed. */
refresh(): void;
/** Changes the rows on screen in place, without reading again: a saved decision updating its
* row. The next answer (a new query, `refresh`, `retry`) replaces them. */
patchRows(change: (rows: Row[]) => Row[]): void;
queryString: string;
}
/** A table over a page-numbered endpoint (`ListEnvelope`): the default. */
interface ServerTable extends ServerTableBase {
pager: Pager;
total: number;
page: number;
totalPages: number;
from: number;
to: number;
setPage(p: number): void;
/** Same query, unpaged — for CSV export and select-all-matching. */
fetchAll(): Promise;
}
/** A table over a cursor-paged endpoint (`KeysetEnvelope`): `paging: "keyset"`. It has no pages, so no
* pager, `setPage` or `fetchAll`; it has the cursor's `loadMore`. */
interface KeysetServerTable extends ServerTableBase {
/** Every row of the match, when the endpoint said; null when it cannot count them. */
total: number | null;
/** Another page follows the last one loaded. */
hasMore: boolean;
/** The next page is being read (the rows stay as they are, so this is not `refreshing`). */
loadingMore: boolean;
/** Reads the page after the last one loaded and appends it. A no-op while a read is in flight, the
* first page is loading, or at the end. */
loadMore(): void;
}
interface UseServerTableOptions {
endpoint: string;
params?: Record;
defaults?: Partial;
/** Extra values that should trigger a refetch. */
deps?: unknown[];
/** Opts into durable rows-per-page and default sort. Page/search/filters are deliberately
* NOT persisted — they describe one task, and restoring them hides rows the user expects. */
prefsKey?: string;
/** The request carries `page` and `pageSize`; the envelope is a `ListEnvelope`. */
paging?: "page";
/** Keeps the query in the address (`?q=ohio&sort=crp&page=2`), so a link reproduces the list and
* Back returns to it. The address wins over `defaults`; it is written when the request is sent,
* after the search debounce. Pass `{}` for the browser's own address, or `{ adapter }` over a
* router's search params; `scope` prefixes the params of one list among several. */
url?: QueryUrlOptions;
}
/** For an endpoint that pages by cursor and cannot cheaply count its rows. The request carries `limit`
* (the page size) and, after the first page, `after` (the cursor); the envelope is a `KeysetEnvelope`.
* Any change of search, filter or sort starts again from the first page. */
interface UseKeysetTableOptions extends Omit {
paging: "keyset";
}
declare function eqFilter(get: (row: Row) => unknown): (row: Row, value: unknown) => boolean;
declare function anyOfFilter(get: (row: Row) => string[]): (row: Row, value: unknown) => boolean;
declare function rangeFilter(get: (row: Row) => number): (row: Row, value: unknown) => boolean;
type Api = {
request(id: string, params?: unknown, options?: {
signal: AbortSignal;
}): Promise;
};
type PrefsStore = {
getTable(key: string): any;
setTable(key: string, patch: any): void;
};
declare global {
interface Window {
PlannerPrefs?: PrefsStore;
}
}
/** React hook — the only way a screen should talk to a list endpoint. Page-numbered by default;
* `paging: "keyset"` returns the cursor-paged table (`KeysetServerTable`). */
declare function useServerTable(options: UseServerTableOptions): ServerTable;
declare function useServerTable(options: UseKeysetTableOptions): KeysetServerTable;
declare const QueryKit: {
DEFAULTS: TableQuery;
/** Register the app's simulated (or real) transport: an object with .request(id, params). */
setApi(api: T): T;
getApi(): Api;
/** Optional durable table preferences store: { getTable(key), setTable(key, patch) }. */
setPrefs(p: PrefsStore): void;
/**
* @param rows the full static dataset (stands in for the table)
* @param query { page, pageSize, sort, dir, q, filters }
* @param cfg { searchKeys[], filters: {id: (row, value) => boolean},
* accessors: {colKey: (row) => sortableValue},
* facets: {id: (row) => facetValue} }
*/
run(rows: Row[], query: Partial, cfg: RunConfig): ListEnvelope;
/** Display form of a query, for the request log and the API spec screen. */
toQueryString(query: Partial): string;
useServerTable: typeof useServerTable;
};
/** Runtime mode and computed feature completeness. */
interface FeatureStatus {
/** Every endpoint this feature needs is served by the real backend. */
complete: boolean;
/** Endpoint ids still unserved. */
missing: string[];
/** No requirements were declared — treated as incomplete (fails closed). */
undeclared?: boolean;
/** Needs no backend at all. */
clientOnly?: boolean;
}
interface RuntimeSummary {
total: number;
complete: number;
incomplete: number;
endpointsWired: number;
}
declare const RuntimeKit: {
WIRED_ENDPOINTS: string[];
CLIENT_ONLY: string[];
FEATURE_NEEDS: Record;
/** Register what each feature needs: { "planner.flights": ["flights.list", "flights.shift"] } */
declare(map: Record): void;
needsOf(key: string): string[];
/** Every declared feature, for the admin surface. */
declared(): string[];
/** Tell this module which endpoints the consuming app has actually wired to a real backend.
* Additive — merges into WIRED_ENDPOINTS, never replaces it, so multiple call sites in a
* consuming app (or a call that runs more than once, e.g. React strict-mode double-invoke)
* compose safely instead of one clobbering another. Call once, early, before first render:
* status() reads WIRED_ENDPOINTS live but nothing here triggers a re-render on its own. */
wireEndpoints(ids: string[]): void;
isEndpointWired(id: string): boolean;
/** The whole point: complete is computed, never stored. */
status(key: string): FeatureStatus;
isComplete(key: string): boolean;
getMode(): "live" | "test";
isTest(): boolean;
setMode(next: "live" | "test"): void;
subscribe(fn: (mode: "live" | "test") => void): () => void;
/** Counts for the admin surface and the mode popover. */
summary(): RuntimeSummary;
};
declare function useRuntimeMode(): "live" | "test";
/** True when this feature should be shown obstructed right now. */
declare function useFeatureStatus(key: string): FeatureStatus & {
mode: "live" | "test";
obstruct: boolean;
};
declare const UseRuntimeMode: typeof useRuntimeMode;
declare const UseFeatureStatus: typeof useFeatureStatus;
interface MockJobStatus {
id: string;
status: "queued" | "running" | "done" | "failed";
progress?: number;
stage?: string;
result?: TResult;
/** Set only when status is "failed" — the thrown value's message. */
error?: string;
}
interface PollOptions {
/** Delay between polls, ms. Default 400 — the rate every hand-written poller used. */
intervalMs?: number;
onTick?: (status: MockJobStatus) => void;
}
/**
* @param prefix id prefix, e.g. "imp" → "imp_1a2b3c"
* @param latency [lo, hi] ms — total time across every stage, jittered within the range
* @param stages progress labels in order, e.g. ["Uploading", "Parsing", "Validating"]
* @param compute runs once, on the final stage, to produce the result — or throws to fail the job
*/
declare function schedule(prefix: string, latency: [number, number], stages: string[], compute: () => TResult): string;
/** Plain status lookup — what a jobs.get-style mock endpoint wraps. Throws if the id is unknown. */
declare function get(jobId: string): MockJobStatus;
/** Polls until the job resolves. Resolves with the result on "done", throws on "failed". */
declare function poll(jobId: string, opts?: PollOptions): Promise;
declare const MockJobKit: {
schedule: typeof schedule;
get: typeof get;
poll: typeof poll;
};
/** One entry in the append-only history. Never mutated after it's appended,
* except for `current` flipping to false when a later version is committed. */
interface VersionRecord {
v: number;
current: boolean;
who: string;
when: string;
action: string;
/** Human-readable change descriptions, as returned by the mutator passed to commit(). */
changes: string[];
/** Present only when the store was created with a `diff` option. */
diff?: TDiff;
/** Open bag for caller-specific before/after figures (e.g. a computed total) that
* don't belong in `diff` because they aren't part of the state comparison itself. */
meta?: Record;
}
interface CreateVersionStoreOptions {
/** Defaults to structuredClone. Override if TState holds something it can't clone
* (a class instance, a function) — must still produce a deep, independent copy. */
clone?: (state: TState) => TState;
/** Computes what a version's `diff` field holds by comparing the state before and
* after the mutation. Omit if the caller has no use for a structured diff — the
* `changes` string list on every record still describes what happened. */
diff?: (before: TState, after: TState) => TDiff;
/** Timestamp/attribution stamp for `when`. Defaults to `new Date().toISOString()`. */
now?: () => string;
/** Version number the initial state is recorded under. Defaults to 0. */
initialVersion?: number;
who?: string;
action?: string;
}
interface VersionStore {
/** The live state. Mutating it directly bypasses versioning — always go through commit(). */
getState(): TState;
/** Newest first, matching the order a version-history panel reads top to bottom. */
getVersions(): VersionRecord[];
getVersion(v: number): VersionRecord | undefined;
/**
* Snapshots the state before mutation, runs `mutate` against the live state, then
* appends a new version recording what changed and keeps a full-state snapshot of
* the result so this version can be restored to or undone past later.
*/
commit(who: string, action: string, mutate: (state: TState) => string[], meta?: Record): VersionRecord;
/** Makes version `v` current by committing its snapshot as a NEW head version —
* history is append-only, so this never rewrites or truncates the list. */
restoreTo(v: number, who?: string): VersionRecord;
/** Restores the state one version behind the current head, committed as a new head
* version (the inverse of the head's change, recorded rather than erased). Throws
* if the head is already the oldest recorded version. */
undo(who?: string): VersionRecord;
}
declare function createVersionStore(initialState: TState, options?: CreateVersionStoreOptions): VersionStore;
interface StackedLayerOptions {
/** Whether the layer is in the stack: it owns a stacking slot (z-index) and gets focus back when it leaves. */
active: boolean;
/** The layer is leaving (its exit animation is playing) but still on screen. It keeps its stacking slot so the
* exit paints where the layer was, yet it is no longer the layer a key press is for: it does not take Escape
* or trap Tab, which go to the layer beneath. Default false. */
exiting?: boolean;
/** The layer's own root element: what counts as inside it for focus. */
containerRef: React.RefObject;
/** A modal layer: while it is the innermost trapping layer, Tab and Shift+Tab stay inside
* it and the layers opened from it, and focus moves into it when it opens. */
trap?: boolean;
/** Called when Escape is pressed while this is the innermost layer. A layer without one
* still takes the press, so it never reaches a layer beneath. */
onEscape?: () => void;
/** When the layer closes while holding focus (or loses focus with its DOM), focus goes back
* to the element that held it when the layer opened. Default true. */
returnFocus?: boolean;
}
/** A layer in the stack. Pass it to `StackedLayerProvider` around what the layer renders, so
* anything opened from inside belongs to it. */
interface StackedLayer {
readonly parent: StackedLayer | null;
}
/** How far a layer's z-index sits above its parent's — also the spacing between two open
* layers that share a parent (siblings stack by open order, oldest lowest). */
declare const LAYER_STEP = 10;
/** The z-index a root layer (no parent) claims: `(index + 1) * ROOT_STEP`, where `index` is
* its position among the currently-open root layers, oldest first. Static, always-mounted
* page chrome (a sticky header, the off-canvas rail's own resting z-index) must stay under
* this, so no page z-index can ever outrank an overlay. */
declare const ROOT_STEP = 100;
/** Guaranteed to sit above any z-index this mechanism can ever compute — see
* TransferList.tsx's drag-ghost use. Not part of the layer tree; nothing derives from it. */
declare const OVERLAY_CEILING = 10000;
/** The z-index `layer` should render at right now — 0 while it is closed (nothing should be
* positioning off it then). Re-renders whenever any layer anywhere opens or closes, since
* a sibling or a parent opening/closing can change this layer's own slot. */
declare function useLayerZIndex(layer: StackedLayer): number;
/** Test-only escape hatch: forces every currently-open layer closed and the shared order
* counter back to zero. Production code never needs this. */
declare const __resetLayerStackForTests: () => void;
/** Joins the stack while `active`. Returns the layer, for `StackedLayerProvider`. */
declare function useStackedLayer(options: StackedLayerOptions): StackedLayer;
/** Makes `layer` the owner of every layer opened from inside `children`, across portals. */
declare function StackedLayerProvider({ layer, children }: {
layer: StackedLayer;
children: React.ReactNode;
}): React.ReactElement;
/** MP-248: whether `node` lies within `layer`'s own DOM, or the DOM of anything opened from
* inside it — any layer it owns, portaled or not. For outside-click dismissal, which must
* not fire for a click that landed inside a further layer this one opened (a Modal opened
* from a Popover's own content) — the stack's real ownership, not a component's own class
* name, which only ever recognised its own kind of layer. */
declare function isWithinLayerTree(layer: StackedLayer, node: Node | null): boolean;
/**
* MP-214: the responsive-rail behaviour every app-level shell was hand-rolling (and, in
* sms-app's case, already diverging on — see the SMS-5 architecture-gate review). Owns:
*
* - overlay-vs-docked detection at `breakpoint` (default `OVERLAY_BREAKPOINT`, matching the CSS in
* components.css's `.fd-side` media query, pinned equal by overlayLayout.test.tsx);
* - the rail's open/closed state, forced closed on entry to overlay and forced open on
* return to docked (matching the desktop-collapse behaviour PlannerShell already had);
* - Escape-to-close while the overlay is open, and a trapping layer in the shared layer stack
* (layerStack.tsx) — Tab/Shift+Tab wrap inside the rail and whatever is opened from inside
* it, and focus returns to whatever was focused before the overlay opened;
* - a body scroll lock while the overlay is open, so the page behind a full-height
* off-canvas rail can't scroll.
*
* `initialOpen`/`onPersist` are the seam for a desktop-only persisted preference (e.g. a
* `usePref`-backed value) — this hook never persists anything itself, and never reads or
* writes storage, so it stays usable in an app with no such preference at all.
*/
interface UseResponsiveRailOptions {
/** Max-width (px) at which the rail becomes an overlay. Must match the CSS breakpoint
* the consumer's `SidebarNav` styling uses — default `OVERLAY_BREAKPOINT`. */
breakpoint?: number;
/** The rail's open state on first render while docked (desktop). Ignored while the
* initial viewport is already in overlay range — an overlay always starts closed. */
initialOpen?: boolean;
/** Called with the new open state whenever it changes *while docked* — the hook's only
* hook into persistence. Never called for an overlay open/close, which is transient by
* design (mirrors PlannerShell's `railPref`, which never persists a mobile toggle). */
onPersist?: (open: boolean) => void;
}
interface UseResponsiveRailResult {
/** Whether the rail is currently visible/expanded. */
open: boolean;
/** True below `breakpoint` — the rail renders as a scrimmed overlay instead of a
* docked column. */
overlay: boolean;
/** Attach to the rail's root DOM node (e.g. `SidebarNav`'s forwarded ref) — required for
* the focus trap and initial-focus behaviour while the overlay is open. */
navRef: React.RefObject;
/** The rail's layer. Wrap the rail's content in `StackedLayerProvider` with it, so a menu
* opened from inside the rail belongs to the rail's focus scope. */
layer: StackedLayer;
/** Flips `open`. Wire directly to `Topbar`'s `onMenu` — works at every width the rail is
* shown at, docked or overlay, never a no-op. */
toggle: () => void;
/** Closes the rail. Never persists, even while docked — for the scrim's onClick and
* Escape, where the value is a transient dismissal rather than a stated preference. */
close: () => void;
/** Sets `open` directly. Persists via `onPersist` only while docked, same rule as
* `toggle`. */
setOpen: (open: boolean) => void;
}
declare function useResponsiveRail(options?: UseResponsiveRailOptions): UseResponsiveRailResult;
/** Max-width (px) at and below which the app shell's side rail is an overlay instead of a
* docked column. The one definition: `useResponsiveRail`, `AppShell` and `useOverlayLayout`
* read it, and an app that changes layout at the same width imports it instead of copying
* the number. `.fd-side`'s media query in styles/components.css is the CSS twin of this
* value (CSS cannot read it), pinned equal by overlayLayout.test.tsx. */
declare const OVERLAY_BREAKPOINT = 1024;
/** True while the layout is in overlay mode (the viewport is at or under
* `OVERLAY_BREAKPOINT`). Read synchronously on first render, so there is no flash. */
declare function useOverlayLayout(): boolean;
/**
* MP-221: the " · · Flytedesk" title convention every flytedesk app shell
* hand-typed per route (sms-app's CampaignAnalytics.tsx, QueryTool.tsx, NotFound.tsx,
* SignIn.tsx and index.html each spelled " · SMS · Flytedesk" separately — SMS-5
* code-quality review item 5). One place composes it now.
*
* `routeTitle` is optional so a route with no title of its own (e.g. a bare loading
* state) still gets a correct " · Flytedesk" title instead of a dangling separator.
*/
declare function formatDocumentTitle(routeTitle: string | undefined | null, appName: string): string;
/**
* Sets `document.title` to `formatDocumentTitle(routeTitle, appName)` for as long as the
* calling component is mounted, and re-applies it whenever either argument changes —
* e.g. call it once per route component with that route's own title. Does nothing
* outside a DOM environment (SSR/tests with no `document`).
*/
declare function useDocumentTitle(routeTitle: string | undefined | null, appName: string): void;
/** How long a control shows "copied" after a successful copy. */
declare const COPIED_FEEDBACK_MS = 1600;
/**
* Puts `text` on the clipboard. Resolves `true` when it landed and `false` when the browser
* refused every route, so a caller confirms only what really happened.
*/
declare function copyToClipboard(text: string): Promise;
/**
* `[copied, copy]`: `copy(text)` writes the clipboard (see `copyToClipboard`) and `copied` is
* true for `COPIED_FEEDBACK_MS` after a copy that succeeded, for a "Copied" label or a check
* icon. A repeat copy restarts that window, and unmounting clears it.
*/
declare function useCopyToClipboard(): [boolean, (text: string) => Promise];
/**
* The one bottom-right corner every floating surface shares. A fixed box cannot know what
* else is fixed to the same corner, so two surfaces that each pin themselves there land on
* top of each other; instead each renders into its own slot of this single stack, and the
* stack lays the slots out one above the next.
*
* The slots do not share a layer. The toasts are fixed at OVERLAY_CEILING: a message about what a
* person just did is read over everything. The jobs slot sits just under the first overlay layer:
* above the page, below every menu, popover, dialog and drawer, so a menu that opens over the bottom
* of the page is never hidden by the tray, and the tray never covers one.
*
* The slots are fixed, so they take no room in the page and would cover whatever scrolls
* beneath it. While the job tray is showing it therefore publishes how far it reaches up from
* the viewport's bottom edge as `--fd-corner-stack-inset` on the document element, and the
* app shell's content scroller adds that to its bottom padding: the last of the content can
* always be scrolled clear of it.
*/
/** The custom property carrying the job tray's reach up from the viewport bottom (0px when it is not showing). The toasts
* stack above it, and the page scrolls clear of it; the toasts, which leave on their own, add no padding. */
declare const CORNER_STACK_INSET_PROPERTY = "--fd-corner-stack-inset";
/** Bottom to top: a surface earlier in this list sits closer to the corner. */
declare const SLOTS: readonly ["jobs", "toasts"];
type CornerStackSlot = (typeof SLOTS)[number];
/** The element that is `slot` in the shared corner stack, or null until the first commit
* (there is no document to hold the stack while rendering on a server). The stack exists
* only while a surface is using it. */
declare function useCornerStackSlot(slot: CornerStackSlot): HTMLElement | null;
/** Renders `children` into `slot` of the shared corner stack. */
declare function CornerStackPortal({ slot, children }: {
slot: CornerStackSlot;
children?: React.ReactNode;
}): React.ReactPortal | null;
/** DS-29: the width of a docked side panel, published on the document element while it shows so the stack
* sits beside it (over the content) instead of over the panel and its composer. */
declare const CORNER_STACK_OFFSET_PROPERTY = "--fd-corner-stack-offset";
/** DS-29: set on the document element while a side-panel sheet (the overlay-breakpoint panel) is open. The sheet
* covers the screen and its composer sits at the bottom, so the jobs slot is hidden until the sheet closes. */
declare const SHEET_OPEN_ATTRIBUTE = "data-fd-sheet-open";
/** Publishes `element`'s rendered width as the stack's right offset for as long as it is mounted. */
declare function usePublishCornerStackOffset(ref: React.RefObject, active: boolean): void;
/** Marks the document while a side-panel sheet is open. */
declare function useSheetOpenMark(active: boolean): void;
/** How long one chat call may take before it fails visibly instead of leaving the conversation waiting for ever. */
declare const CHAT_CALL_DEADLINE_MS = 30000;
declare class CallDeadlineError extends Error {
readonly call: string;
readonly deadlineMs: number;
constructor(call: string, deadlineMs: number);
}
/** Settles as `work` does, or rejects with a `CallDeadlineError` once `deadlineMs` has passed with no answer. The work
* itself is not cancelled (an app's client may offer no way to); the caller just stops waiting. */
declare function withDeadline(call: string, deadlineMs: number, work: Promise): Promise;
/** The exact string the chat engine matches to show its "isn't reachable" state (never substring sniffing). */
declare const CHAT_UNAVAILABLE_MESSAGE = "chat_unavailable";
/** What a transport throws when it could not reach its api at all, so the adapter can tell an unreachable chat from a
* server's own stable error code (which the panel matches by exact equality and the adapter never rewrites). */
declare class ChatUnreachableError extends Error {
constructor(message?: string);
}
interface ChatThreadRef {
thread_id: string;
}
/** The page the chat is opened on, as the engine passes it when it resolves a thread. */
interface ChatThreadContext {
contextType: string;
contextId: string;
}
/** A turn the app runs as a dispatch: start it, then the engine polls its status until it ends. */
interface PollingTurns {
/** Starts a turn (a dispatch, not a stream) and answers the job id the engine tracks. `attachments` are the files the
* person added; a transport that cannot carry them must throw rather than drop them. */
startTurn: (threadId: string, text: string, attachments?: Attachment[]) => Promise<{
jobId: string;
}>;
/** The turn's status, or null when the server no longer knows the turn. */
getTurn: (jobId: string) => Promise;
streamTurn?: never;
}
/** A turn the app runs as one live stream: the engine appends what `handlers` are given, and Stop aborts `signal`. */
interface StreamingTurns {
/** Runs the turn to its end. It resolves the turn's final result (no `job_id`: a streaming chat has no turn status
* to poll) and throws `ChatUnreachableError` when the api cannot be reached. It carries no deadline: a live stream
* may run as long as the turn does. */
streamTurn: (threadId: string, text: string, handlers: StreamHandlers, signal: AbortSignal, attachments?: Attachment[]) => Promise;
startTurn?: never;
getTurn?: never;
}
/**
* What an app supplies to put a chat on the design system's panel: its own request client and its own mapping from its
* wire onto the design system's generic `ChatMessage`, `JobStatus` and `ThreadSummary`. The design system names no route,
* client or wire type; each member is the app's call, answering the generic shape. Its turns are either polled
* (`startTurn` + `getTurn`) or streamed (`streamTurn`), never both.
*/
type ChatTransport = {
/** The person's current thread. The server always answers one, so a failure means the api is unreachable. */
resolveThread: (context: ChatThreadContext) => Promise;
/** A new conversation: the person's newest thread when that one is still empty, else a fresh one. */
createThread: () => Promise;
/** The person's threads that hold a message, newest activity first, as the panel's history lists them. */
listThreads: (signal: AbortSignal) => Promise;
getMessages: (threadId: string) => Promise;
/** The Stop button of a polled turn: cancels it upstream. A streamed turn is stopped by aborting its signal. */
cancelTurn?: (jobId: string) => Promise;
} & (PollingTurns | StreamingTurns);
interface ChatAdapterOptions {
/** Called once a turn has been accepted (a polled turn when its dispatch is accepted, a streamed one at its first
* stream event), so the person's thread list can show a first message. */
onTurnStarted?: () => void;
deadlineMs?: number;
}
/** The `ChatAdapter` for `useChatEngine`, over an app's transport. It adds what every chat needs and no app should
* re-write: a deadline on every call but a live stream, the exact "chat_unavailable" failure of an unreachable api, a
* vanished turn read as failed, and the signal that a turn started. `useChatEngine` owns every state-machine rule
* (optimistic send, polling, retry). */
declare function createChatAdapter(transport: ChatTransport, { onTurnStarted, deadlineMs }?: ChatAdapterOptions): ChatAdapter;
declare const THREADS_LOAD_FAILED = "Your earlier conversations could not be loaded.";
declare const NEW_THREAD_FAILED = "A new conversation could not be started.";
interface ChatThreads {
/** The person's threads that hold a message, newest activity first. */
threads: ThreadSummary[];
/** Reads the list again: after a New conversation, and after a turn is sent (so a first message shows up). */
refresh: () => void;
/** New conversation: asks the transport for a thread, hands its id to `onCreated` (to switch the panel to it) and
* re-reads the list. A failure shows an error toast and calls nothing. */
startNewThread: (onCreated: (threadId: string) => void) => Promise;
}
interface UseChatThreadsOptions {
deadlineMs?: number;
}
/** The person's chat threads for the panel's History, and its New conversation. The list is read when the panel opens and
* on `refresh`. Needs a `ToastProvider`. The transport may be built inline each render: only its latest members are used. */
declare function useChatThreads(transport: Pick, { deadlineMs }?: UseChatThreadsOptions): ChatThreads;
/** What a person is told about an export: its wire status, with a finished one past its expiry told apart. */
type ExportState = "queued" | "preparing" | "ready" | "expired" | "failed";
declare function exportStateOf(job: ExportJob, now: number): ExportState;
/** The time an export is judged expired by: when it was drawn, moved on to the moment a ready file expires while it is open. */
declare function useExpiryClock(job: ExportJob): number;
/**
* The export as a view of it should show, when it holds its own copy (`held`, a chat thread's) beside the shared jobs'
* list (`exports`, `refresh`). While the held copy is live, the last copy the shared jobs listed wins once they have
* listed it; a finished export never changes, so the held copy of it stands. A live export the jobs do not list yet has
* them re-read once, and from then on their poll moves it to its end: the view adds no poll of its own.
*/
declare function useShownExport(held: ExportJob, exports: ExportJob[], refresh: () => void): ExportJob;
interface ExportDownloaderOptions {
/** A fresh signed link for the export, or null when the route says it is not downloadable right now (expired, or its
* status changed underneath the click). */
mintUrl: (id: string) => Promise;
/** Where the browser goes to take the file. Its own `Content-Disposition` names it, so the page stays where it is. */
navigate?: (url: string) => void;
}
/** The one-click download of a finished export, shared by the tray and the CSV card so the two never differ. The returned
* function resolves once the click has been handled and never rejects: a link that is not available, or cannot be
* minted (a network blip, a 500), leaves the page as it is, the next read of the export corrects what the person sees,
* and the next click tries again. */
declare function createExportDownloader({ mintUrl, navigate, }: ExportDownloaderOptions): (id: string) => Promise;
/** While any export is PENDING/RUNNING: frequent enough that a person watching the tray sees it move, not so frequent it
* hammers the server. */
declare const EXPORT_POLL_INTERVAL_MS = 3000;
/** Once nothing is live: still catches an export another tab or the job's own backstop resolves, without polling an
* idle tray every few seconds for ever. */
declare const EXPORT_IDLE_POLL_INTERVAL_MS = 30000;
interface UseExportJobsOptions {
/** The person's exports as the server lists them. It lists only what should show: the hook does no filtering, so a
* dismissed or aged-out export leaves the tray because the server stopped listing it. */
list: () => Promise;
/** Dismisses a finished export from the tray, server-side, so it is gone after a reload too. */
acknowledge: (id: string) => Promise;
}
interface UseExportJobsResult {
exports: ExportJob[];
/** Dismisses the export: it leaves the list at once, the server is told, and the list is re-read (which also puts the
* export back if the server could not be told). */
ackExport: (id: string) => void;
/** Reads the list now instead of at the next poll: an export started elsewhere shows at once. */
refresh: () => void;
}
/** The person's own export jobs, polled while anything is live. Entirely server-persisted: every render derives from the
* last list read, so a reload mid-export picks the same export back up from the next poll. Mount it once, above both the
* control that starts an export and the tray that shows it, so they share one poll. */
declare function useExportJobs({ list, acknowledge }: UseExportJobsOptions): UseExportJobsResult;
declare const EXPORT_KEPT_NOTE = "Kept for 24 hours";
declare const EXPORT_FAILED_FALLBACK = "Unknown error.";
/** An export as the tray's card shows it. `useExportJobs`' `list` returns only what should show, so a SUCCEEDED export is downloadable. */
declare function exportToJob(job: ExportJob, onDownload: (id: string) => void): Job;
interface ExportTrayProps {
exports: ExportJob[];
onDownload: (id: string) => void;
onDismiss: (id: string) => void;
}
/** The person's background exports as a `JobTray` in the shared bottom-right corner stack. With no exports it renders
* nothing: an empty card would hold the corner for no reason. */
declare function ExportTray({ exports, onDownload, onDismiss }: ExportTrayProps): React.JSX.Element;
export { AccountMenu, type AccountMenuLink, type AccountMenuProps, type AdaptableSession, AgentChatPanel, type AgentChatPanelProps, AnchoredPopover, type AnchoredPopoverProps, AnimNum, type AnimNumProps, ApiSpecBrowser, type ApiSpecBrowserProps, type AppEntry, AppShell, type AppShellProps, type AppShellSidePanel, AppSwitcher, type AppSwitcherProps, AppearanceMenu, type AppearanceMenuProps, type AppearancePrefs, AppearanceProvider, type AppearanceProviderProps, type AppearanceStore, type AppearanceStoreOptions, type Attachment, Avatar, type AvatarProps, AvatarStack, type AvatarStackProps, Badge, type BadgeProps, Banner, type BannerProps, type BannerTone, BarList, type BarListProps, type BarListRow, BudgetReallocator, type BudgetReallocatorProps, Button, type ButtonProps, CHANNELS, CHANNEL_WEIGHTS, CHAT_CALL_DEADLINE_MS, CHAT_UNAVAILABLE, CHAT_UNAVAILABLE_MESSAGE, COPIED_FEEDBACK_MS, CORNER_STACK_INSET_PROPERTY, CORNER_STACK_OFFSET_PROPERTY, CSV_FAILED_FALLBACK, CSV_KEPT_NOTE, Calendar, type CalendarProps, CallDeadlineError, Card, CardGrid, type CardGridProps, type CardProps, CardRow, type CardRowProps, ChannelContribution, type ChannelContributionChannel, type ChannelContributionProps, ChannelMeta, type ChannelMetaRecord, ChannelTag, type ChannelTagProps, ChannelWeightOf, type ChatAdapter, type ChatAdapterOptions, ChatComposer, type ChatComposerProps, type ChatEngine, type ChatEngineOptions, ChatKit, type ChatMessage, type ChatRenderContext, ChatSessionBar, type ChatSessionBarProps, type ChatThreadContext, type ChatThreadRef, type ChatThreads, ChatTranscript, type ChatTranscriptProps, type ChatTransport, ChatTurn, type ChatTurnProps, ChatUnreachableError, Checkbox, type CheckboxProps, type Citation, Citations, type CitationsProps, Clamp, type ClampProps, ClockFace, type ClockFaceProps, CodeBlock, type CodeBlockProps, CodeEditor, type CodeEditorDiagnostic, type CodeEditorProps, Collapsible, type CollapsibleProps, ComingSoon, type ComingSoonProps, type ContextUsage, CopyButton, type CopyButtonProps, CornerStackPortal, type CornerStackSlot, type CreateVersionStoreOptions, CsiBadge, type CsiBadgeProps, CsiHero, type CsiHeroProps, CsiMeter, type CsiMeterProps, CsvCard, type CsvCardProps, DEFAULT_PREFS, DEFAULT_STORAGE_KEY, DEFAULT_TOAST_DURATION_MS, DataTable, type DataTableColumn, type DataTableColumnLabel, type DataTablePhoneRole, type DataTableProps, type DataTableRowAction, DatePicker, type DatePickerProps, type DemoSessionControls, DemoSessionProvider, type DeviceSession, DiffBlock, type DiffBlockProps, DonutChart, type DonutChartProps, type DonutSegment, Drawer, type DrawerProps, Dropzone, DropzoneKit, type DropzoneProps, ENTITY_ROW_METRIC_WIDTH, EXPORT_FAILED_FALLBACK, EXPORT_IDLE_POLL_INTERVAL_MS, EXPORT_KEPT_NOTE, EXPORT_POLL_INTERVAL_MS, EditableTitle, type EditableTitleProps, type EditorTrigger, EmptyState, type EmptyStateProps, type EndpointSpec, EntityRow, type EntityRowAction, type EntityRowContent, type EntityRowMetric, EntityRowMetrics, type EntityRowMetricsProps, type EntityRowProps, type ExportDownloaderOptions, type ExportJob, type ExportJobStatus, type ExportState, ExportTray, type ExportTrayProps, FILE_VIEWER_CSV_ROW_LIMIT, FILE_VIEWER_TEXT_LIMIT, type Fact, FactList, type FactListProps, FeatureGate, type FeatureGateProps, type FeatureStatus, Fieldset, type FieldsetProps, FileBrowser, type FileBrowserFile, type FileBrowserFolder, type FileBrowserPermissions, type FileBrowserProps, FileChip, type FileChipProps, FileGrid, type FileGridProps, FileKit, FilePickButton, type FilePickButtonProps, type FileRejection, FileStrip, type FileStripProps, FileTile, type FileTileProps, type FileUploadHandler, FileViewer, type FileViewerProps, FilterBar, type FilterBarProps, type FilterDateValue, type FilterField, type FilterFieldDate, type FilterFieldFixed, type FilterFieldHelp, type FilterFieldMultiSelect, type FilterFieldOption, type FilterFieldRange, type FilterFieldSelect, type FilterFieldText, type FilterMultiSelectValue, type FilterOperator, type FilterRangeValue, type FilterResolveMatch, type FilterResolveResult, type FilterValidation, type FilterValue, Flag$1 as Flag, type FlagExplanation, type FlagProps, FlytedeskBrand, type FlytedeskBrandProps, FlytedeskMark, type FlytedeskMarkProps, FormatEta, FunnelChart, type FunnelChartProps, type FunnelStep, Gate, GatePending, type GatePendingProps, type GateProps, HOVER_POPOVER_OPEN_DELAY_MS, type HoverPopoverApi, IMPORTANCE_LEVELS, IconButton, type IconButtonProps, type ImportanceLevel, type ImportanceLevelId, ImportanceTag, type ImportanceTagProps, type ImportanceTokens, InfoButton, type InfoButtonProps, Input, type InputProps, type Job, type JobAction, type JobCardStatus, type JobStatus, JobTray, type JobTrayProps, Json, KeyHint, type KeyHintProps, type KeysetEnvelope, type KeysetServerTable, LAYER_STEP, type ListEnvelope, LoadingRegion, type LoadingRegionProps, Markdown, MarkdownEditor, type MarkdownEditorHandle, type MarkdownEditorProps, MarkdownInline, type MarkdownProps, type MarkdownSize, type MarkdownTone, type MentionSource, Menu, MenuButton, type MenuButtonProps, type MenuItem, type MenuProps, MessageBody, type MessageBodyProps, type MessageGroup, Meter, type MeterProps, type MeterSegment, type MeterSegmentInput, MixGap, type MixGapProps, type MixGapRow, MockJobKit, type MockJobStatus, Modal, type ModalProps, ModeSwitch, type ModeSwitchProps, type Model, ModelControls, type ModelControlsProps, NEW_THREAD_FAILED, NumberInput, type NumberInputProps, OVERLAY_BREAKPOINT, OVERLAY_CEILING, POPOVER_BODY_MIN, type Packet, PacketCard, type PacketCardProps, type PacketDecision, type PacketDecisionAction, type PacketDecisionState, type PacketDecisionTone, type PacketSchema, type Pager, Pagination, type PaginationProps, PanelRow, type PanelRowProps, PermissionDenied, type PermissionDeniedProps, type PermissionGroup, PermissionHint, type PermissionItem, type Phase, type PhaseStatus, PhasedProgress, type PhasedProgressProps, type Placement, type PollOptions, type PollingTurns, Popover, type PopoverProps, type PreferenceCriterion, PreferenceMeter, type PreferenceMeterProps, type ProfileFieldDef, type ProfileFieldType, type ProfileNotificationDef, ProfilePage, type ProfilePageProps, ProgressBar, type ProgressBarProps, QUERY_EMPTY_MESSAGE, QueryKit, type QueryPaging, QueryResultCard, type QueryResultCardProps, type QueryResultStatus, type QueryUrlAdapter, type QueryUrlOptions, type QueuedTurn, QuotaRow, type QuotaRowProps, ROOT_STEP, Radio, RadioGroup, type RadioGroupOption, type RadioGroupProps, type RadioProps, RangeSlider, type RangeSliderProps, type ReferenceEntry, ReferenceList, type ReferenceListProps, type ReferenceSection, RelativeTime, type RelativeTimeProps, ReservedSkeleton, type ReservedSkeletonProps, type RoadmapItem, type RoadmapResult, RoadmapTimeline, type RoadmapTimelineProps, type Role, RunActions, type RunActionsProps, type RunConfig, RuntimeKit, type RuntimeSummary, SHEET_OPEN_ATTRIBUTE, SaturationDistribution, type SaturationDistributionCampus, type SaturationDistributionProps, ScoreMeter, type ScoreMeterProps, ScreenHead, type ScreenHeadProps, SearchField, type SearchFieldProps, SegmentedControl, type SegmentedControlProps, SegmentedMeter, type SegmentedMeterProps, Select, type SelectOption, type SelectProps, type SendMessageResult, type ServerTable, type ServerTableBase, type Session, type SessionAdapterUser, type SessionAppFeedEntry, type SessionContextValue, type Flag as SessionFlag, SessionKit, SessionProvider, type SessionProviderProps, type SessionRole, SessionScreen, type SessionScreenKind, type SessionScreenProps, type SessionStats, type SessionUser, SidebarNav, type SidebarNavItem, type SidebarNavProps, Skeleton, type SkeletonProps, SkeletonText, type SkeletonTextProps, type SlashCommand, Slider, type SliderProps, type SortDirection, type SortEntry, SortMenu, type SortMenuField, type SortMenuProps, type SpecModule, Spinner, type SpinnerProps, type StackedLayer, type StackedLayerOptions, StackedLayerProvider, StatGrid, type StatGridProps, StatTile, type StatTileProps, StatusIndicator, type StatusIndicatorProps, type Step, StepList, type StepListProps, type StreamHandlers, type StreamingTurns, type Suggestion, SurroundSound, type SurroundSoundProps, Switch, type SwitchProps, THREADS_LOAD_FAILED, type TabItem, type TableQuery, Tabs, type TabsProps, Tag, type TagProps, TestModeBar, type TestModeBarProps, type TextScale, Textarea, type TextareaProps, type ThemeMode, ThinkingBlock, type ThinkingBlockProps, type ThreadSummary, TimePicker, type TimePickerProps, Toast, type ToastApi, type ToastOptions, type ToastProps, ToastProvider, Tooltip, type TooltipApi, type TooltipBinding, type TooltipPlacement, type TooltipProps, Topbar, type TopbarProps, TranscriptKit, TransferList, type TransferListProps, type TransferListSide, type TransferListSideProps, TrendChart, type TrendChartProps, type TrendSeries, USChoropleth, type USChoroplethProps, type StateFigure as USChoroplethState, type ChoroplethValue as USChoroplethValue, US_STATE_CODES, type UsageLimit, type UseChatThreadsOptions, type UseExportJobsOptions, type UseExportJobsResult, UseFeatureStatus, type UseHoverPopoverOptions, type UseKeysetTableOptions, type UseResponsiveRailOptions, type UseResponsiveRailResult, UseRuntimeMode, type UseServerTableOptions, type UseTooltipOptions, type User, VIRTUAL_LIST_BUFFER_ROWS, VIRTUAL_LIST_ROW_GAP, type VersionRecord, type VersionStore, VirtualList, type VirtualListProps, type VirtualLoadedBand, type VirtualWindow, type VoiceHandler, __resetLayerStackForTests, acceptMatches, anyOfFilter, applyAppearance, applyPrefs, channelWeightOf, computeNeedMore, computeVirtualWindow, copyToClipboard, createChatAdapter, createExportDownloader, createLocalStorageAppearanceStore, createVersionStore, eqFilter, exportStateOf, exportToJob, extensionOf, extractClipboardFiles, filterFiles, formatAbsolute, formatBytes, formatClock, formatDocumentTitle, formatDuration, formatEta, formatRelative, formatShare, groupMessages, hasModifier, historyUrlAdapter, iconForMime, importanceLevel, importanceTokens, installFlytedeskFavicon, isExpiredExport, isImage, isLiveExport, isSystemMessage, isUsStateCode, isWithinLayerTree, jobBucket, languageLabel, loadPrefs, meterFormats, modifierLabel, parseDelimited, pastedTextName, pluralizeUnit, preferenceScore, preferenceSegments, rangeFilter, readQueryFromUrl, resolveTheme, revokeAttachment, savePrefs, toAttachment, toSwitcherEntries, tokenize, useAppearancePrefs, useAppearanceStore, useChatEngine, useChatThreads, useCopyToClipboard, useCornerStackSlot, useDocumentTitle, useExpiryClock, useExportJobs, useFeatureStatus, useHoverPopover, useLayerZIndex, useOverlayLayout, usePopoverPosition, usePublishCornerStackOffset, useResponsiveRail, useRuntimeMode, useServerTable, useSessionContext, useSessionContextValue, useSheetOpenMark, useShownExport, useStackedLayer, useStagedFiles, useToast, useTooltip, validateFilterValue, visibleMessages, watchAppearance, withDeadline, writeQueryToUrl };