Bloxwap / HIG0.1.1
GuidelinesFoundations

Numbers, currency, dates and built-in strings

Issue #47. Vocabulary is locked in the trading glossary; agents consume docs/foundations/glossary.json. Machine-readable format profiles: docs/foundations/localization.json. Host owns locale selection, parsing and time zone; HIG formats display only.

HIGProvider locale is a BCP 47 tag (en-US default). Direction (ltr/rtl) is independent. de-DE and ar-EG are Intl fixtures already covered by tests, not approved translation locales.

Shared helpers

Use the display helpers from @bloxwap/hig/contracts and the data readouts. Do not hand-roll separators, currency symbols, compact suffixes, the leverage × or zone labels. Every helper returns — for null, undefined and non-finite input.

import {formatNumber, formatLeverage, formatTicker, formatZonedTime} from '@bloxwap/hig/contracts';
formatNumber(value, locale, options);             // Intl.NumberFormat
formatLeverage(10, locale);                        // '10×' (decimal style forced)
formatTicker(12.34, 'USDC', locale, {signDisplay: 'exceptZero'}); // '+12.34 USDC' (no-break space)
formatZonedTime(instant, locale, 'UTC');           // '4:00 PM UTC' / '16:00 UTC'
NeedAPI
Decimal/grouped numbersformatNumber / NumberReadout + host minimumFractionDigits/maximumFractionDigits
USDUsd (style:'currency', currency:'USD')
USDCformatTicker(amount, 'USDC', locale, options) — decimal amount plus untranslated ticker, never $
LeverageformatLeverage(value, locale) — 10×, never percent
Percent pointsPercent (5 means 5%; do not pass a ratio)
Signed PnLsignDisplay:'exceptZero' on Usd/formatNumber/formatTicker
Compact totalsnotation:'compact', compactDisplay:'short'
Funding / expiry clocksformatZonedTime(instant, locale, timeZone) — Intl.DateTimeFormat with the zone name always shown
Other datesIntl.DateTimeFormat
Funding countdownIntl.RelativeTimeFormat on a duration, not a zone-less clock

Hermes in RN 0.81.5 and 0.83.10 provides Intl. Don’t polyfill, don’t concatenate '$'+value, and don’t parse a formatted string back into an order.

Numbers

Grouping and decimal marks come from the locale: 1,234.5 in en-US, 1.234,5 in de-DE, Eastern Arabic numerals in ar-EG. Precision is host-supplied (tick size, szDecimals). Generic USD balances may use 2 fraction digits; prices, sizes, funding rates and fees do not share one precision table.

Do keep 0 for zero and — for unknown/non-finite; mark stale. Don’t use a rounded display value as transaction input, strip unfamiliar separators, or replace Eastern Arabic digits with Latin ones unless a dual readout is explicit.

Readout prefix and suffix are host-localized display strings, not parsers or substitutes for Intl currency, sign, percent, or compact formatting. Animated readouts generate digit glyphs from the active numbering system and always settle on the same canonical Intl reading as the static component.

Currency, signed PnL and compact notation

USD uses ISO currency:'USD'. Intl places the symbol ($1,234.50 vs 1.234,50 $). USDC is a ticker, not a reliable ISO 4217 code: use formatTicker(amount, 'USDC', locale), which formats the number without style:'currency' and appends USDC after a no-break space. Never show USDC as $ or USD.

Signed PnL always includes a sign except at zero (+$12.34, -$12.34). Color is not a substitute for the sign. Don’t use accounting parentheses.

Compact notation is locale-owned. 1.2K / 3.4M is the en-US short form; de-DE yields 3,4 Mio., ja-JP may use 万. Never hard-code K/M. Compact is for overview totals, not order tickets or review amounts.

USDC PnL follows the same sign rule: +12.34 USDC. Tabular nums on every readout.

Dates, times and funding

en-US clocks are 12-hour (4:00 PM). Many other locales resolve h23. Use Intl.DateTimeFormat (hourCycle from the locale unless the host overrides). Store instants in UTC.

Funding and expiries: compute the next payment from UTC (Hyperliquid funding is hourly; the interval is protocol-owned). Display the instant with an explicit zone (16:00 UTC or a named device zone) using formatZonedTime. Pair a duration countdown (“in 12 min”) with that labeled clock. Perps have no expiry — don’t render an expiry field on a perp.

Don’t show a funding time without a zone or treat a local wall clock as UTC.

Leverage and percentages

Leverage is a multiplier with the untranslated × suffix (10×, via formatLeverage), never 1000% or percent styling. Percentages use the glossary’s rate terms (funding, builder fee as 5 bps (0.05%)) and Percent for percent-point values (+2.5%, -2.5%). Keep bps untranslated.

RTL and text expansion

Logical inline spacing, wrapping labels, no fixed-height clipping of translated text, Dynamic Type stays on. Plan for ~30–40% expansion (German/Finnish) and 200% text. Directional arrows mirror in RTL; brand, check marks and financial direction (+/−, buy/sell) do not. Preserve bidi isolates that Intl inserts around signed currency in Arabic. Native per-place motion uses the intact static reading whenever separate reels cannot preserve that bidi layout; it never reverses formatted parts. Mixed-script punctuation needs the required font scripts; a Latin-only fixture does not certify them.

Long translations, Arabic/Hebrew RTL and large text remain part of the platform checklist. Physical-device observations are still pending.

Native direction from the host locale

Issue #86. On native, HIGProvider follows I18nManager.isRTL when direction is unset. React Native 0.82 and later, including 0.83.10, do not report an Android per-app RTL locale through I18nManager (Android 13+ cmd locale set-app-locales ar-EG, or a per-app language picker). I18nManager.isRTL stays false, so an Arabic or Hebrew user would get a left-to-right HIG tree. RN 0.81.5 and iOS are not affected.

Hosts that lay out right to left pass the locale's direction:

import {getLocales} from 'expo-localization';
import {HIGProvider, localeDirection} from '@bloxwap/hig/native/provider';

const direction = localeDirection(getLocales()[0]); // 'rtl' for ar-EG; undefined keeps the I18nManager default
<HIGProvider locale={getLocales()[0]?.languageTag} direction={direction}>{children}</HIGProvider>;

localeDirection (also exported from @bloxwap/hig/contracts) returns expo-localization's textDirection when present. Otherwise it reads the tag's script, then its language: ar_EG, he-IL and az-Arab-IR give rtl; en-US, ar-Latn and en-u-nu-arab give ltr. It accepts BCP 47 tags, Android/Java identifiers and objects with textDirection/languageTag, and returns undefined for no language ('', und). Without expo-localization, React Native's I18nManager.getConstants().localeIdentifier is the app's configuration locale on Android (from the RN 0.83 source; the gallery verifies the expo-localization path).

  • Don't pass a locale-derived direction in an app that disallows RTL (I18nManager.allowRTL(false), or android:supportsRtl="false"). Leave direction unset so HIG follows the OS layout. HIG never derives direction on its own for this reason.
  • Don't expect I18nManager.isRTL to change on RN 0.83 Android. Native views HIG does not draw keep the host's direction.

Upstream: react/react-native#53417 (0.82.0) made I18nUtil.isDevicePreferredLanguageRTL read Locale.getAvailableLocales()[0] instead of the app's locale. The bug is reported in #54938 and fixed by #57635, first released in 0.88.0. 0.83.10, 0.84.1, 0.85.3, 0.86.3 and 0.87.1 still have the bug. The native gallery passes localeDirection(getLocales()[0]), and its os-arabic Android emulator step asserts rtl on RN 0.81.5 and 0.83.10 (evidence/native-sim/issue-86-88-89/). A system-wide Arabic locale and physical devices are not yet tested (#55).

Built-in component strings

Issue #62. HIG components render a small amount of their own copy: the Close button on overlays, the Toast Dismiss button, the Button busy suffix, loading and empty states, and the recipe labels. None of it is hard-coded in packages/hig/react/src or packages/hig/native/src. Every string comes from a typed HIGMessages catalog (@bloxwap/hig/contracts/messages). Hosts translate it through HIGProvider messages. A few components also take a per-instance prop (closeLabel, cancelLabel, dismissLabel, busyLabel).

HIG ships the English (en-US) defaults only (defaultMessages). It does not ship or guess translations. The host's message catalog owns French, Korean and other copy, under the same rule as the glossary: an unapproved locale is never filled by guessing.

import {HIGProvider} from '@bloxwap/hig/react/provider'; // or '@bloxwap/hig/native/provider'
import type {HIGMessages} from '@bloxwap/hig/contracts/messages';

const fr: Partial<HIGMessages> = {
  close: t('hig.close'),             // from the host catalog, e.g. 'Fermer'
  dismiss: t('hig.dismiss'),
  dismissNotice: (message, dismiss) => `${dismiss} : ${message}`,
  busy: t('hig.busy'),
};

<HIGProvider locale='fr-FR' messages={fr}>…</HIGProvider>
<Dialog closeLabel={t('checkout.back')} …/>   // one instance
<Button busy busyLabel=''>…</Button>           // no visible suffix; busy state stays exposed

Resolution order, per key: the component prop, then the nearest HIGProvider messages, then any enclosing HIGProvider messages, then English. A nested provider for density or direction keeps the host's translations. undefined keys and values of the wrong type (a string where a function is expected, or the reverse) are ignored, so a partial catalog never blanks a label. useMessages() returns the complete resolved catalog; useHIG().messages holds only the host overrides. Each English default is also a named export of @bloxwap/hig/contracts/messages, and a component falls back per key (messages.close ?? en.close), so importing one component bundles only the copy it renders.

Rules:

  • Visible text and accessible names come from the same key. The Close, Dismiss and busy text is the button content, so it is also the accessible name. The Toast dismiss name is dismissNotice(message, dismiss). A translation must keep the dismiss argument in it, so voice-control users can say the label they see. stale and required are appended to both the visible text and the accessible name.
  • A busy Button keeps its name (#94). When a native Button has text children and is busy, HIG sets its accessible name, because React Native Android otherwise announces only the busy state. On iOS the name is the visible {text} · {busy} (the text alone when busy is ''). On Android the name is {text}: Android appends its own localized busy state description, so the suffix would be spoken twice where the host's busy matches it (Korean 처리 중). An explicit accessibilityLabel always wins.
  • Slider actions are labeled (#87). The native Slider's increment and decrement accessibility actions carry increase and decrease. React Native 0.83 Android otherwise lists them with an empty label.
  • Close means dismissing an overlay. close closes a dialog or sheet. It is not the glossary sense of closing a position (an exit order). Translate it with the platform's dismiss word, never with the trading term for closing a position.
  • Functions receive formatted arguments. pageOf and progressValue receive numbers already formatted for the provider locale (Eastern Arabic digits in ar-EG). resultCount receives the raw count and the locale, so it can pick a plural form with Intl.PluralRules. Translations may reorder arguments. Component code never concatenates words.
  • RTL is a direction, not a translation. Messages render inside the provider's dir (web) or writingDirection (native). The Unicode bidi algorithm places the · busy separator and parentheses. Do not add direction marks to messages.
  • Trading words stay with the host. Default messages contain no glossary term. The TradeEntry recipe copy (reviewAssetOrder, confirmOrder, …) is generic order-review copy. The host supplies the asset ticker, which stays untranslated.
KeyEnglish defaultWhereProp override
closeCloseDialog, Sheet, Popover (web), Menu and GestureSheet (native) close buttoncloseLabel
cancelCancelAlertDialog (web) decline buttoncancelLabel
dismissDismissToast dismiss button textdismissLabel
dismissNotice (message, dismiss){dismiss} {message}Toast dismiss accessible name; must contain the dismiss argument—
busyWorkingButton busy suffix, shown as · {busy}; '' hides itbusyLabel
notificationsNotificationsToast region name (web)—
loadingLoadingSpinner label (web appends …)label
loadingContentLoading contentSkeleton labellabel
loadingItemsLoading itemsList loading row—
loadingChartLoading chart…PriceChart fallback (web, @bloxwap/hig/chart)—
noItemsNo itemsList empty textempty
noRowsNo rowsTable empty text (web)—
retryRetryList retry button—
loadMoreLoad moreList load-more button—
stale(stale)NumberReadout stale marker (visible and accessible)—
required(required)Field required marker (visible and accessible)—
breadcrumbBreadcrumbBreadcrumb landmark name (web)—
paginationPaginationPagination landmark name (web)—
previousPreviousPagination previous button—
nextNextPagination next button—
pageOf (page, total)Page {page} of {total}Pagination position; numbers arrive locale-formatted—
searchSearchSearch field label—
searchingSearching…Search in-progress status—
noResultsNo resultsSearch empty status—
resultCount (count, locale){count} result / {count} resultsSearch result count (native); use Intl.PluralRules for other plural forms—
backBackBackButton (native)—
menuDescriptionAvailable actionsMenu sheet description (native)—
chooseChooseSelect trigger before a choice (native)—
chooseOneChoose one option.Select sheet description (native)—
inProgressIn progressIndeterminate Progress value text (native)—
progressValue (value, max){value} of {max}Determinate Progress value text (native); numbers arrive locale-formatted—
copiedCopiedInstallCommand success status—
copyFailedCopy failed. Select the command and copy manually.InstallCommand failure status—
copyInstallCommandCopy install commandInstallCommand button—
reviewAssetOrder (asset)Review {asset} orderTradeEntry heading—
amountInAsset (asset)Amount ({asset})TradeEntry amount label—
availableAmount (available)Available: {available}TradeEntry amount description—
reviewOrderReview orderTradeEntry review button—
confirmOrderConfirm orderTradeEntry dialog title and confirm button—
confirmOrderDescription (amount, asset)Amount: {amount} {asset}. Verify the host-provided terms before submission.TradeEntry dialog description—
orderUnavailableOrder unavailableTradeEntry error title (web)—
noActivityNo activity yetActivityList empty text—
statusOnlineonlineStatusIndicator online—
statusOfflineofflineStatusIndicator offline—
statusStalestaleStatusIndicator stale—
increaseIncreaseSlider increment accessibility action label (native; listed by Android accessibility services)—
decreaseDecreaseSlider decrement accessibility action label (native)—

npm run lint runs scripts/check-built-in-strings.mjs, and so does tests/built-in-strings.test.ts. The check fails when a component under packages/{react,native}/src adds a user-visible or accessible literal. That covers JSX text, a visible or accessibility attribute (aria-label, accessibilityLabel, title, label, description, …), a label-like default prop and a set…() status string. To add copy, add a key to HIGMessages, read it with useMessages(), and add a row to the table above. The test checks that every key is in this table with its English default.

Validation

See tests/tokens.test.ts, tests/data.dom.test.tsx, tests/glossary.test.ts, tests/built-in-strings.test.ts, tests/messages.dom.test.tsx, tests/native/messages.test.cjs, docs/foundations/localization.json and the data chapter. Cite HIG 0.1.0 with this chapter anchor and a commit permalink.

The native Button's busy suffix starts with the separator (· Working); the row supplies its visual gap. On iOS its accessible name remains Action · Working. Android speaks the action name and its localized busy state once.

On this page