Host integration
Embedding ox-charts in your app: symbol search and instrument specs, the trading adapter (including brokers that return no order id), ChartShell props, the trading dock, host theme, storage, and installing the package or linking its source.
This is the guide for a company embedding the terminal: Phoenix first, others
later. It covers the datafeed calls the chart needs per symbol (search,
names, instrument specs), the one function surface you implement to trade
(the trading adapter), the ChartShell props that wire your app to the
chart, feature switches, the trading dock, live theming, storage namespacing
and how to install the package, or consume it from source while you work on both.
The library is in development. Interfaces change outright; there is no
compatibility layer. Everything here is what packages/ox-charts/src/ does
today.
1. The datafeed: search, names and instrument specs
Beside history and the live stream, two optional IDatafeed calls decide how
well the chart knows your instruments.
import type { IDatafeed, SymbolInfo, SymbolSearchFilter, SymbolType } from "@option-x-tech/ox-charts";
interface IDatafeed {
getHistory(req: HistoryRequest): Promise<OhlcBar[]>;
subscribe(symbol: string, resolution: Resolution, onBar: (bar: OhlcBar) => void): () => void;
searchSymbols?(query: string, filter?: SymbolSearchFilter): Promise<SymbolInfo[]>;
resolveSymbol?(symbol: string): Promise<SymbolInfo | null>;
readonly exchanges?: readonly string[]; // the search dialog's exchange row
readonly symbolTypes?: readonly SymbolType[]; // its category tabs; default index, stock, future, option
}
type SymbolType = "index" | "stock" | "future" | "option" | "crypto" | "forex" | "commodity" | "etf";
interface SymbolSearchFilter {
type?: SymbolType; // the selected category tab; absent on "All"
exchange?: string; // the selected exchange; absent on "All", only sent when you list `exchanges`
}
interface SymbolInfo {
symbol: string; // the id: getHistory, subscribe and the adapter receive it
name: string; // what every surface shows, e.g. "BANKNIFTY 26 SEP 52500 CE"
exchange: string;
type: SymbolType;
tickSize: number; // > 0; prices snap to it
decimals?: number; // price decimals; default: what tickSize implies (0.05 → 2, 0.0025 → 4)
lotSize?: number; // units per lot (NIFTY 75); default 1
description?: string;
}Search filters server-side. The dialog's category tabs and exchange row
travel as filter, so query per type on your server. The dialog shows exactly
what you return (it does not filter again), so an empty query with a category
selected should return that category's defaults: the Options tab lists
options, not an empty slice of the all-category popular list. While a request
is in flight the list says "Searching…", never "No symbols found". The chosen
tab and exchange are remembered between openings; a remembered choice your
feed no longer offers falls back to "All", so it can never hide results.
Names are readable everywhere. The toolbar, cell headers, the legend, the
search dialog's bold label, the trading panel's symbol columns, the ticket and
the confirm cards show SymbolInfo.name, never the raw id (the id stays in the
tooltip). A symbol picked from search shows its name at once; any other symbol
shows its raw id until resolveSymbol answers.
Specs are per symbol. Tick size, decimals and lot size come from
resolveSymbol, per symbol, cached once per symbol and datafeed object
(instrumentsFor(datafeed)). The price axis, the OHLC legend, the quote row,
the ticket, the order and position lines, the risk gate's lot limits and the
lots → units conversion all use the spec of the symbol they are about: a 2×2
grid can show NIFTY (0.05, lots of 75) beside USD/INR (0.0025, lots of 1,000).
There is no shell-wide tick size, decimals or lot size.
Until a symbol's spec is known, it is not tradable: the quote row and the
replay bar's buttons are disabled with the reason on hover, the "+" menu
offers no orders, the ticket does not send, and the controller refuses any
placement or reversal on it (no_instrument in the audit) instead of guessing
a lot size of 1. Closing a position and cancelling orders always work. A feed
without resolveSymbol, a null answer, a non-positive tick size or a lot
size that is not a positive whole number all leave the symbol untradable, with
the reason in the ticket.
2. The trading adapter (the contract)
One adapter serves one account and every symbol in it. The chart never
asks "which instrument does this adapter trade": every call names its symbol,
every Order and Position carries one, and the subscriptions publish the
whole account. Each grid cell filters to the symbol it shows.
import type {
ITradingAdapter, PlaceOrderRequest, PlaceOrderResult, PlaceOrderAck, ModifyOrderRequest,
Order, Position, TradingCapabilities, HostMeta,
} from "@option-x-tech/ox-charts";
interface ITradingAdapter {
readonly capabilities?: TradingCapabilities;
placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResult>; // Order | PlaceOrderAck
modifyOrder(id: string, changes: ModifyOrderRequest): Promise<Order>;
cancelOrder(id: string): Promise<void>;
closePosition(symbol: string): Promise<void>;
reversePosition?(symbol: string): Promise<void>; // optional
modifyPositionBrackets(symbol: string, tpPrice?: number, slPrice?: number): Promise<void>;
subscribeOrders(cb: (orders: Order[]) => void): () => void; // all symbols, emits synchronously on subscribe
subscribePositions(cb: (positions: Position[]) => void): () => void; // all open positions; [] when flat
getOrders(): Order[];
getPositions(): Position[];
}
interface PlaceOrderRequest {
symbol: string; // the cell's symbol, or a resolved option contract
side: "buy" | "sell";
type: "market" | "limit" | "stop";
qty: number; // UNITS (lots × the symbol's lot size), never lots
price: number; // limit/stop price; for market, the last price the chart saw
tpPrice?: number; // optional brackets (see capabilities)
slPrice?: number;
clientOrderId: string; // always set by the controller; reject duplicates, ECHO IT
tag?: string; // "ox:<instance>:<pineId>:<role>" on script orders; echo it
source: "ticket" | "quote" | "axis-plus" | "line-drag" | "ladder" | "panel" | "script" | "host";
meta?: HostMeta; // your fields: Readonly<Record<string, string | number | boolean>>
}
/** What placeOrder may answer when the broker has not assigned an id yet. */
interface PlaceOrderAck {
clientOrderId: string; // the request's, echoed
status: "pending-new";
}
type PlaceOrderResult = Order | PlaceOrderAck;
interface ModifyOrderRequest { price?: number; qty?: number; tpPrice?: number; slPrice?: number; meta?: HostMeta }
interface TradingCapabilities {
supportsBrackets?: boolean; // TP/SL on orders and positions (default true)
bracketsRequireStopLoss?: boolean; // a TP needs an SL with it (default false)
canModifyRestingBrackets?: boolean; // a resting order's TP/SL can be modified (default true)
supportsMarketOrders?: boolean; // type: "market" (default true)
supportsStop?: boolean; // type: "stop" (default true)
supportsReverse?: boolean; // defaults to `reversePosition !== undefined`
supportsModifyQty?: boolean; // default true
supportsModifyPrice?: boolean; // the draggable order line (default true)
}Order carries id, symbol, side, type, price, qty, status, tpPrice?, slPrice?, createdAt, filledPrice?, filledAt?, cancelReason?, bracketOf?, clientOrderId?, tag?, source?, meta?. Position carries symbol, qty (signed), avgPrice, tpPrice?, slPrice?, openedAt?, initialStop?; openedAt is optional, and a
position without it shows "—" in the panel's Held column. A flat symbol has no
entry in the positions list. status is one of open | pending-new | pending-modify | pending-cancel | filled | cancelled; a pending-* status
renders the line as "sending" and a rejection (a rejected promise) snaps it
back to the last snapshot, so publish your snapshots truthfully and let the
promise carry the error message (Error.message is what the toast and the
ticket show).
Optional: implement ITradingLedger (subscribeAccount, subscribeClosedTrades)
and the panel gains the account figures, the Closed trades tab and Stats.
Without it the panel still shows Orders, Positions (open P&L from the
chart's marks), Automation and the tester.
Brokers whose place endpoint returns no order id
Phoenix's POST /order answers without an order id; the order appears later
on the orders stream. The contract covers this:
placeOrderresolves with{ clientOrderId, status: "pending-new" }(the request'sclientOrderId) instead of anOrder.- The controller shows a placeholder order with id
pending:<clientOrderId>and statuspending-new: a "sending" line on the chart, an "Awaiting broker" row in the panel, a "sent · waiting for the broker" toast. A placeholder cannot be dragged, modified or cancelled (there is no broker id yet), and it counts against the position limit. - When
subscribeOrderspublishes anOrderwhoseclientOrderIdequals the request's, that order replaces the placeholder in the same emission: one line, one row, no ghost. The audit row that sent it is linked to the broker's order id, and a later fill is recorded on it. If the broker published the order beforeplaceOrderresolved, the controller links it directly and never shows a placeholder. - A placeholder not matched within
tradingOptions.orderConfirmTimeoutMs(default 15 s) leaves the chart and becomes an "order status unknown — check the order book" row: a red row at the top of the Orders tab with Dismiss, a badge on the panel strip and the status-bar toggle, a toast, and the audit decisionunknown. It keeps counting against the position limit until the broker publishes it (it is then reconciled, "confirmed by the broker after the timeout") or the user dismisses it.
What the host must echo: clientOrderId on every Order it publishes
(and tag on script orders). An order published without it can never be
matched to its placeholder, so both show until the placeholder times out.
controller.subscribeOrderEvents(cb) reports { kind: "confirmed", order, late } and { kind: "unknown", order };
controller.getSnapshot().unknownOrders lists what is unknown;
controller.dismissUnknownOrder(clientOrderId) clears a row.
Capabilities: hide what the broker cannot do
The UI hides or disables unsupported actions up front instead of failing at call time, and the controller refuses them before calling you:
| Flag | What changes |
|---|---|
supportsBrackets: false | No TP/SL rows in the ticket, no TP/SL handles on lines |
bracketsRequireStopLoss: true | The ticket asks for an SL before it sends a TP; the position line offers TP only once an SL is set, and the SL cannot be cleared while a TP remains |
canModifyRestingBrackets: false | A resting order's TP/SL legs are drawn but cannot be dragged |
supportsMarketOrders: false | The ticket offers Limit/Stop only; the quote buttons (and one-click) open a limit ticket at the last price |
supportsStop: false | No Stop in the ticket or the "+" menu |
supportsReverse: false | No ↕ on the position chip |
supportsModifyPrice: false | Order lines cannot be dragged to a new price (✕ still cancels) |
What the chart does with it
Every chart-originated action reaches your adapter only through the
trading controller (createTradingController, built by ChartShell around
your adapter): the ticket, the quote row, the price-axis "+", a dragged order
line or bracket, a line's ✕, the panel's Close, and every script intent. The
controller assigns clientOrderId, checks the capabilities and the symbol's
spec, runs the risk gate (account-wide kill switch, cool-down, order-rate
counters and daily P&L; position limits per symbol, in that symbol's lots),
records an audit row with the source and symbol, then calls you. Confirm
mode queues a card first.
Pseudo-code for a host:
const adapter: ITradingAdapter = {
capabilities: { supportsReverse: false, supportsModifyQty: false, bracketsRequireStopLoss: true },
async placeOrder(r) {
await api.post("/order", {
symbol: r.symbol, side: r.side, type: r.type, qty: r.qty, price: r.price,
tp: r.tpPrice, sl: r.slPrice, clientId: r.clientOrderId, tag: r.tag,
product: r.meta?.product ?? "MIS",
});
// No id yet: the order arrives on the stream, carrying clientId.
return { clientOrderId: r.clientOrderId, status: "pending-new" };
},
modifyOrder: (id, c) => api.patch(`/orders/${id}`, c).then(toOrder),
cancelOrder: (id) => api.delete(`/orders/${id}`),
closePosition: (symbol) => api.post(`/positions/${symbol}/close`),
modifyPositionBrackets: (symbol, tp, sl) => api.post(`/positions/${symbol}/brackets`, { tp, sl }),
subscribeOrders(cb) { cb(cache.orders); return socket.on("orders", (rows) => cb(rows.map(toOrder))); },
subscribePositions(cb) { cb(cache.positions); return socket.on("positions", (rows) => cb(rows.map(toPosition))); },
getOrders: () => cache.orders,
getPositions: () => cache.positions,
};
// toOrder MUST map the broker's client id back: { ..., clientOrderId: row.clientId, tag: row.tag }Idempotency: the same clientOrderId must never create a second order. Tags
must be echoed: a paused strategy reconciles its resting orders by tag when
it resumes. meta is yours; the chart never reads it.
Reaching the controller from outside: build it yourself and pass it as
trading (the shell will not wrap it twice), or read useShell().tradingController
inside. A controller you build needs the instrument specs:
import { createTradingController, instrumentsFor } from "@option-x-tech/ox-charts";
const controller = createTradingController(adapter, {
instrumentFor: instrumentsFor(datafeed).spec, // the same registry the shell uses
orderConfirmTimeoutMs: 15_000,
});Then controller.setKillSwitch(true), controller.pauseAll("feed down"),
controller.getSnapshot().audit, controller.subscribeMarks() for the last
price per symbol.
Start with createPaperBroker(): it implements the whole contract plus the
ledger, one account over every symbol, and the shell feeds it every cell's
bars (onBar(symbol, bar)). The demo's ?broker=ack runs it behind a place
endpoint that returns no id (and ?broker=ack-lost one that never publishes);
?caps=noMarket,requireSl,fixedBrackets,noStop makes it declare less.
3. ChartShell props
interface ChartShellProps {
datafeed: IDatafeed; // §1: resolveSymbol supplies names and specs
defaultSymbol: string;
defaultResolution: Resolution;
theme?: OxTheme | string; // a name seeds the picker; an OBJECT is followed live
themes?: readonly OxTheme[];
colorThemes?: readonly ColorTheme[];
colorTheme?: ColorTheme | string;
features?: ShellFeatures; // §4
onActiveCellChange?: (cell: { cellId: string; symbol: string; resolution: Resolution }) => void;
onCellSymbolChange?: (cellId: string, symbol: string) => void;
storageKey?: string; // prefix for every persisted key; default "ox"
trading?: ITradingAdapter;
tradingOptions?: TradingOptions; // currencySymbol, locale, qtyPresets (lots), commissionPercent,
// orderConfirmTimeoutMs,
// automation: { allowAutoExecution, scriptLimits, manualLimits,
// confirmTimeoutMs, resolveOptionContract }
defaultIndicators?: readonly DefaultIndicatorSpec[];
defaultLayout?: GridLayoutId;
storage?: { get(key: string): string | null; set(key: string, value: string): void };
scriptStore?: ScriptStore;
docsBaseUrl?: string;
}tradingOptions carries no tickSize, decimals or lotSize: those come
per symbol from SymbolInfo (§1).
docsBaseUrl is the docs root that the toolbar's Docs button and the "?" help
buttons open pages under, in a new tab. It defaults to the published docs,
https://optionx.trade/charts/docs (DEFAULT_DOCS_BASE_URL). To use a
self-hosted copy, pass its root with the /docs path included, such as
http://localhost:3000/docs. features.learn: false hides the links.
onActiveCellChange fires once when the shell mounts, with the focused
cell it restored from storage (so you can mirror it without reaching into
useShell), and then whenever the focused cell changes or its symbol /
interval does: a focus click, symbol search, the interval menu, a symbol-sync
fan-out, or a layout change that moves the focus. Change notifications fire
from the event handler that made the change, never from an effect.
onCellSymbolChange fires on changes only. Use them to drive your own
watchlist, option chain or news pane.
4. Feature switches
Everything the shell is capable of is on by default (trading needs an
adapter, the footprint a footprint feed, the heatmap a depth feed). Switch
families off with false, or pieces with the object form:
<ChartShell
features={{
trading: { ticket: true, axisPlus: true, dragLines: false, panel: true, panelPlacement: "bottom" },
automation: { modes: ["test", "paper", "confirm"] }, // "auto" also needs allowAutoExecution
scripts: false,
indicators: true,
drawings: true,
orderflow: { footprint: true, depthHeatmap: false, profile: true },
replay: false,
alerts: true,
learn: false,
layouts: ["1", "2h", "2x2"], // or false to hide the picker
gridSync: true,
themePicker: false,
}}
/>Inside the shell, chrome reads useShell().features (a ResolvedFeatures,
every flag resolved) and useShell().tradingCapabilities (the adapter's
capabilities with defaults filled in): no ticket means no quote row and
only "Draw horizontal line" on the "+" menu; no stop support drops the Stop
entries; no brackets hides the TP/SL rows and handles; no reverse hides ↕.
5. The trading panel (Orders, Positions, Closed, Stats, Automation, Tester)
With a trading adapter, the status bar at the bottom of the shell carries a
Trading toggle (with the account's working orders, positions and any
unknown orders beside it). It opens a resizable dock under the charts: drag
its top edge to resize, press ⌄ in its strip (or the toggle) to hide it. Its
open state and height persist with the rest of the shell state under
storageKey. Opening, closing or resizing the dock never remounts a chart.
features.trading.panel: falseremoves the panel and its toggle.features.trading.panelPlacement: "none"keeps the panel feature on but the shell renders no dock and no toggle, so you can place<TradingPanel>yourself (a tab in your own layout, a drawer). It fills the box you give it:
import { TradingPanel, useShell } from "@option-x-tech/ox-charts/ui";
import { instrumentsFor } from "@option-x-tech/ox-charts";
// Inside <ChartShell> (as a child), with the shell's own controller:
function MyDock() {
const { tradingController, instruments } = useShell();
return tradingController ? <TradingPanel controller={tradingController} instruments={instruments} currencySymbol="₹" /> : null;
}
// Or anywhere, with a controller you built and passed as `trading` (§2):
<TradingPanel controller={controller} instruments={instrumentsFor(datafeed)} currencySymbol="₹" />6. Host-controlled theme
Pass a theme object and the shell follows it live on every prop change:
--ox-* variables on the shell root, the shadcn bridge, every chart's canvas
colours, the trading lines and order-flow layers. Its dark flag sets the
mode, and the base picker / mode toggle are not offered (hide the colour
picker too with features.themePicker: false).
Build the object from your tokens:
import { themeFromTokens, tokensFromCssProperties } from "@option-x-tech/ox-charts/ui";
// From a plain token object (Phoenix's tokens on every theme change):
const theme = themeFromTokens(
{ background, foreground, surface, border, mutedForeground, primary, up, down, radius, font },
isDark ? "dark" : "light",
{ name: "phoenix" },
);
// Or read them off your CSS custom properties:
const tokens = tokensFromCssProperties(document.documentElement, {
background: "--background", foreground: "--foreground", border: "--border",
primary: "--primary", up: "--chart-up", down: "--chart-down", surface: "--card",
});
<ChartShell theme={themeFromTokens(tokens, mode)} features={{ themePicker: false }} />Keep the object's identity stable between renders (memoise on your theme
state) so the shell re-themes only when the tokens change. createTheme(base, overrides) remains for hosts that want to start from a built-in.
7. Storage
Everything the shell persists (grid state, the trading dock, per-symbol
drawings, the replay session, script drafts and the default local script
store) goes through the storage prop under one namespace.
storageKey="acme" rewrites the default ox prefix (ox-shell:v1 →
acme-shell:v1, ox:scripts:v1:items → acme:scripts:v1:items), so two
shells or two hosts on one origin never share state. With a storageKey and
no storage, localStorage is used.
8. Installing the package
@option-x-tech/ox-charts is published to GitHub Packages, private to the
option-x-tech organisation. The published package is compiled ES modules with
TypeScript declarations (dist/), so your bundler treats it like any other
dependency. It is built from packages/ox-charts in the ox-charts
monorepo (pnpm + Turborepo). Entry points:
| Import | What |
|---|---|
@option-x-tech/ox-charts | engine, datafeeds, trading core (createTradingController, instrumentsFor, brokers, types), and everything in ./ui |
@option-x-tech/ox-charts/ui | ChartShell, TradingPanel, themes, themeFromTokens, shell context |
@option-x-tech/ox-charts/react | OxChart and useOxChart, the bare canvas |
From GitHub Packages
-
Registry. Send the
@option-x-techscope to GitHub Packages in your app's.npmrc. The line holds no secret, so commit it:@option-x-tech:registry=https://npm.pkg.github.com -
Token. Reading the package takes a GitHub token with
read:packagesthat can see the option-x-tech organisation. Keep it out of the repo.-
Locally, create a classic personal access token with
read:packagesand store it in your user config (~/.npmrc):pnpm config set "//npm.pkg.github.com/:_authToken" <token>. -
In CI, write it to the user
.npmrcbefore the install. pnpm does not expand variables in a project.npmrc's credentials, so the token can't live there:- run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}GITHUB_TOKENcan read the package once the package's settings (Manage Actions access) grant your repository access. Otherwise store a classic token withread:packagesas a secret and use that.
-
-
Install.
reactandreact-dom18 or later are peer dependencies; your app's own copies are used.pnpm add @option-x-tech/ox-charts
A Vite 8 app needs nothing else: no alias, no tsconfig paths, no React
dedupe, no optimizeDeps entry and no React Compiler exclusion. The package
imports your app's react, its declarations type-check against your own
@types/react, and the React Compiler leaves node_modules alone. TypeScript
needs moduleResolution: "bundler" (Vite's default) to follow the exports
map and the extensionless imports inside the declarations.
Vite 7 and earlier. In dev, these versions pre-bundle dependencies into
node_modules/.vite/deps, which breaks the script worker's relative URL: the
browser gets a 404 for oxscript-worker.js. Production builds are fine. Keep
the package out of pre-bundling, and pre-bundle the CommonJS shim it depends on
by name instead:
// vite.config.ts (Vite 7 and earlier only)
optimizeDeps: {
exclude: ["@option-x-tech/ox-charts"],
include: [
"@option-x-tech/ox-charts > @base-ui/react > use-sync-external-store/shim",
"@option-x-tech/ox-charts > @base-ui/react > use-sync-external-store/shim/with-selector",
],
},Versions. Releases follow semver, and while the version is 0.x a minor
release (0.1 → 0.2) may change interfaces outright; there is no compatibility
layer. A patch release only fixes. Depend on a caret range such as ^0.1.0,
which on 0.x takes patches only, and read the release notes before moving to
the next minor.
Developing against local source (React 19 hosts)
To change the library and a host together, link the package from a checkout
of the monorepo instead of installing it. In the repo, the package's
main/exports point at src/*.ts, so the host's bundler compiles the
TypeScript source; only the packed package points at dist/.
Linking
# link the package, not the monorepo root
cd ox-charts && pnpm install --frozen-lockfile
cd packages/ox-charts && pnpm link --global # or: npm link
cd ../../../phoenix && pnpm link --global @option-x-tech/ox-charts # or: npm link @option-x-tech/ox-chartsA path dependency works too: "@option-x-tech/ox-charts": "link:../ox-charts/packages/ox-charts".
The library's own dependencies (@base-ui/react, CodeMirror,
@phosphor-icons/react, react-resizable-panels, tailwind-merge, clsx)
resolve from packages/ox-charts/node_modules, so install the monorepo first.
React: one runtime, your types
The library declares react / react-dom >=18 as peers and develops
against React 18 types. A React 19 host must make the linked source use its
own React, at runtime and in the type checker.
-
Types. Point
reactandreact-domat your own@typesintsconfig.json, so the linked source type-checks against your React 19 types instead of the React 18 ones in the library'snode_modules(the source importsJSXfromreact, never the removed global):{ "compilerOptions": { "moduleResolution": "bundler", "jsx": "react-jsx", "target": "ES2022", "paths": { "react": ["./node_modules/@types/react"], "react/*": ["./node_modules/@types/react/*"], "react-dom": ["./node_modules/@types/react-dom"], "react-dom/*": ["./node_modules/@types/react-dom/*"] } } }moduleResolution: "bundler"(ornode16) resolves theexportsmap. The source isnoUncheckedIndexedAccess-clean, so any strictness works.apps/webin the monorepo is a React 19 consumer set up exactly this way. -
Runtime. Dedupe React in Vite so the linked source never loads a second copy (two Reacts break every hook), and keep the linked package out of dependency pre-bundling so edits in the library are picked up without a re-pre-bundle:
// vite.config.ts export default defineConfig({ resolve: { dedupe: ["react", "react-dom"] }, // preserveSymlinks stays false (the default) optimizeDeps: { exclude: ["@option-x-tech/ox-charts"] }, }); -
React Compiler. The library is written without the React Compiler in mind (it reads refs in render and drives imperative engine handles), so compile your app, not the linked source. Exclude it in
babel-plugin-react-compiler'ssources:// vite.config.ts react({ babel: { plugins: [["babel-plugin-react-compiler", { sources: (filename: string) => !filename.includes("/packages/ox-charts/") && !filename.includes("/@option-x-tech/ox-charts/"), }]], }, }),
CSS
Import nothing for the shell: it injects its own compiled stylesheet at
runtime (ensureUiStyles), its Tailwind classes are prefixed ox: and
preflight is off, so your global styles are untouched and your own Tailwind
never needs to scan the library. What you must provide is a sized container:
the shell fills 100% of its parent.
Workers
The OxScript sandbox runs in a module worker. The source creates it with
new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }), and
the published package with the same call on ./oxscript-worker.js, a
self-contained module in dist/ beside the code that spawns it. Vite emits
the worker as its own file in a production build and serves it in dev; Vite 8
also follows the URL when it pre-bundles the package, and earlier versions
need the optimizeDeps entry under From GitHub Packages.
Any bundler must support new Worker(new URL(…, import.meta.url)) with
type: "module". The worker is a same-origin script file, so a Content
Security Policy must allow it (worker-src 'self'). An environment with no
Worker at all (SSR, tests) gets the in-process runner.
Minimal host
import { ChartShell, themeFromTokens } from "@option-x-tech/ox-charts/ui";
import type { IDatafeed, ITradingAdapter } from "@option-x-tech/ox-charts";
export function Terminal({ feed, adapter, tokens, mode }: Props) {
const theme = useMemo(() => themeFromTokens(tokens, mode, { name: "phoenix" }), [tokens, mode]);
return (
<div style={{ height: "100%" }}>
<ChartShell
datafeed={feed} // searchSymbols(q, filter) + resolveSymbol with name/tickSize/lotSize
defaultSymbol="NSE:NIFTY"
defaultResolution="5"
theme={theme}
features={{ themePicker: false, learn: false }}
trading={adapter} // placeOrder may answer { clientOrderId, status: "pending-new" }
tradingOptions={{ currencySymbol: "₹" }}
onActiveCellChange={(c) => setSelectedSymbol(c.symbol)} // also fires once on mount
storageKey="phoenix"
storage={{ get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v) }}
/>
</div>
);
}