@multiplatform.one/theme 6.4.0 → 6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "6.4.0",
3
+ "version": "6.4.1",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -53,8 +53,8 @@
53
53
  "@tamagui/toast": "2.0.0-rc.41",
54
54
  "@tamagui/web": "2.0.0-rc.41",
55
55
  "react-cookie": "^8.1.2",
56
- "@multiplatform.one/store": "6.4.0",
57
- "@multiplatform.one/platform": "6.4.0"
56
+ "@multiplatform.one/platform": "6.4.1",
57
+ "@multiplatform.one/store": "6.4.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@tamagui/animations-css": "2.0.0-rc.41",
@@ -69,8 +69,8 @@
69
69
  "tamagui": "2.0.0-rc.41",
70
70
  "typescript": "~5.9.3",
71
71
  "vitest": "^4.1.5",
72
- "@multiplatform.one/config": "6.4.0",
73
- "@multiplatform.one/test-utils": "6.4.0"
72
+ "@multiplatform.one/test-utils": "6.4.1",
73
+ "@multiplatform.one/config": "6.4.1"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@tamagui/animations-css": "^2.0.0-rc",
package/src/dates.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  * current year elided (`year: "auto"`)
13
13
  * - editing surfaces → compact absolute with the year always shown
14
14
  * ("Jul 1, 2026") — pickers are precision contexts
15
+ * - calendar chrome → the day/month/time-of-day registers below
16
+ * (weekday and month names, "2 PM" hour ticks,
17
+ * "2:00 PM" times, "Tuesday, March 10" day names)
18
+ * - date axis ticks → compact absolute with `year: "never"`, because
19
+ * the surrounding chrome already states the year
15
20
  * No default may emit verbose locale output with seconds.
16
21
  */
17
22
 
@@ -21,9 +26,11 @@ export interface AbsoluteDateFormatOptions {
21
26
  /**
22
27
  * `"auto"` (default) elides the current year — the display register for
23
28
  * tables, feeds, and system events. `"always"` keeps it — the editing
24
- * register for picker display values.
29
+ * register for picker display values. `"never"` drops it — the axis-tick
30
+ * register, where a wider label would collide with its neighbours and the
31
+ * period header carries the year.
25
32
  */
26
- year?: "auto" | "always";
33
+ year?: "auto" | "always" | "never";
27
34
  /** Include the time component ("Aug 9, 3:30 PM"). Default false. */
28
35
  withTime?: boolean;
29
36
  /** BCP-47 locale forwarded to Intl. Default: system locale. */
@@ -78,7 +85,7 @@ export function formatAbsoluteDate(
78
85
  month: "short",
79
86
  day: "numeric",
80
87
  };
81
- if (year === "always" || displayYear !== referenceYear) {
88
+ if (year === "always" || (year !== "never" && displayYear !== referenceYear)) {
82
89
  intlOptions.year = "numeric";
83
90
  }
84
91
  if (withTime) {
@@ -128,3 +135,134 @@ export function formatRelativeTimestamp(value: DateInput, now: number = Date.now
128
135
  const yr = Math.floor(day / 365);
129
136
  return `${Math.max(1, yr)}y ago`;
130
137
  }
138
+
139
+ /** Clock-time shape ("HH:MM" / "HH:MM:SS") — how Frappe `Time` values arrive. */
140
+ export const CLOCK_TIME = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
141
+
142
+ export interface TimeOfDayFormatOptions {
143
+ /** BCP-47 locale forwarded to Intl. Default: system locale. */
144
+ locale?: string;
145
+ /** Minutes ride along by default; `false` is the hour-tick register. */
146
+ minutes?: boolean;
147
+ /** Pin the 12/24-hour clock. Default: whatever the locale uses. */
148
+ hour12?: boolean;
149
+ }
150
+
151
+ /**
152
+ * Time-of-day register ("2:00 PM", or "2 PM" with `minutes: false` for
153
+ * hour ticks) — calendar time grids, Frappe `Time` fields, and
154
+ * "last updated" chrome. Accepts a clock-time string as well as a date,
155
+ * and never emits seconds.
156
+ */
157
+ export function formatTimeOfDay(value: DateInput, options: TimeOfDayFormatOptions = {}): string {
158
+ const { locale, minutes = true, hour12 } = options;
159
+ let date: Date | null = null;
160
+ if (typeof value === "string") {
161
+ const clock = value.trim().match(CLOCK_TIME);
162
+ if (clock) {
163
+ const parsed = new Date(2000, 0, 1, Number(clock[1]), Number(clock[2]));
164
+ if (!Number.isNaN(parsed.getTime())) date = parsed;
165
+ }
166
+ }
167
+ if (!date) {
168
+ const coerced = coerceDateInput(value);
169
+ if (!coerced) return typeof value === "string" ? value : "";
170
+ date = coerced.date;
171
+ }
172
+ const intlOptions: Intl.DateTimeFormatOptions = { hour: "numeric" };
173
+ if (minutes) intlOptions.minute = "2-digit";
174
+ if (hour12 !== undefined) intlOptions.hour12 = hour12;
175
+ try {
176
+ return date.toLocaleTimeString(locale, intlOptions);
177
+ } catch {
178
+ return typeof value === "string" ? value : "";
179
+ }
180
+ }
181
+
182
+ export interface DayLongFormatOptions {
183
+ locale?: string;
184
+ /** Lead with the weekday ("Tuesday, March 10"). Default true. */
185
+ weekday?: boolean;
186
+ /** Append the year ("Tuesday, March 10, 2026"). Default false. */
187
+ year?: boolean;
188
+ }
189
+
190
+ /**
191
+ * Long day register ("Tuesday, March 10") — calendar day headers and the
192
+ * accessible names that read a date aloud, where the compact absolute
193
+ * register is too terse to speak.
194
+ */
195
+ export function formatDayLong(value: DateInput, options: DayLongFormatOptions = {}): string {
196
+ const coerced = coerceDateInput(value);
197
+ if (!coerced) return typeof value === "string" ? value : "";
198
+ const { locale, weekday = true, year = false } = options;
199
+ const intlOptions: Intl.DateTimeFormatOptions = { month: "long", day: "numeric" };
200
+ if (weekday) intlOptions.weekday = "long";
201
+ if (year) intlOptions.year = "numeric";
202
+ if (coerced.dateOnly) intlOptions.timeZone = "UTC";
203
+ try {
204
+ return coerced.date.toLocaleDateString(locale, intlOptions);
205
+ } catch {
206
+ return typeof value === "string" ? value : "";
207
+ }
208
+ }
209
+
210
+ export interface MonthYearFormatOptions {
211
+ locale?: string;
212
+ /** `"short"` ("Mar 2026", the tick register) or `"long"` ("March 2026"). */
213
+ month?: "short" | "long";
214
+ }
215
+
216
+ /** Month-year register ("Mar 2026") — period headers and month axis ticks. */
217
+ export function formatMonthYear(value: DateInput, options: MonthYearFormatOptions = {}): string {
218
+ const coerced = coerceDateInput(value);
219
+ if (!coerced) return typeof value === "string" ? value : "";
220
+ const { locale, month = "short" } = options;
221
+ const intlOptions: Intl.DateTimeFormatOptions = { month, year: "numeric" };
222
+ if (coerced.dateOnly) intlOptions.timeZone = "UTC";
223
+ try {
224
+ return coerced.date.toLocaleDateString(locale, intlOptions);
225
+ } catch {
226
+ return typeof value === "string" ? value : "";
227
+ }
228
+ }
229
+
230
+ /** Day-of-month register ("10") — the densest date axis tick. */
231
+ export function formatDayOfMonth(value: DateInput, options: { locale?: string } = {}): string {
232
+ const coerced = coerceDateInput(value);
233
+ if (!coerced) return typeof value === "string" ? value : "";
234
+ const intlOptions: Intl.DateTimeFormatOptions = { day: "numeric" };
235
+ if (coerced.dateOnly) intlOptions.timeZone = "UTC";
236
+ try {
237
+ return coerced.date.toLocaleDateString(options.locale, intlOptions);
238
+ } catch {
239
+ return String(coerced.date.getDate());
240
+ }
241
+ }
242
+
243
+ export interface WeekdayNamesOptions {
244
+ locale?: string;
245
+ /** `"short"` ("Mon"), `"long"` ("Monday") or `"narrow"` ("M"). */
246
+ style?: "short" | "long" | "narrow";
247
+ /** Week start, 0 = Sunday (default) — the index base callers rely on. */
248
+ firstDay?: number;
249
+ }
250
+
251
+ /** Weekday names for calendar chrome, in week order from `firstDay`. */
252
+ export function getWeekdayNames(options: WeekdayNamesOptions = {}): string[] {
253
+ const { locale, style = "short", firstDay = 0 } = options;
254
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: style, timeZone: "UTC" });
255
+ // 2004-01-04 is a Sunday, so day-of-week and offset line up.
256
+ return Array.from({ length: 7 }, (_, i) =>
257
+ formatter.format(new Date(Date.UTC(2004, 0, 4 + ((firstDay + i) % 7)))),
258
+ );
259
+ }
260
+
261
+ /** Month names for calendar chrome and picker headers, January first. */
262
+ export function getMonthNames(
263
+ options: { locale?: string; style?: "long" | "short" } = {},
264
+ ): string[] {
265
+ const { locale, style = "long" } = options;
266
+ const formatter = new Intl.DateTimeFormat(locale, { month: style, timeZone: "UTC" });
267
+ return Array.from({ length: 12 }, (_, i) => formatter.format(new Date(Date.UTC(2000, i, 1))));
268
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./theme/index";
2
2
  export * from "./dates";
3
+ export * from "./numbers";
3
4
  export * from "./devWarn";
4
5
  export * from "./keyboardFocusRing";
5
6
  export * from "./menuRow";
package/src/numbers.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Canonical number / currency registers (LC-12 render channel, Axiom 9).
3
+ *
4
+ * One formatter owns every number the catalog renders — table aggregates,
5
+ * KPI values, report cells, chart value labels — so the same data class
6
+ * never speaks in two voices ("15400" in a group header beside "15,400" in
7
+ * the cell two rows below).
8
+ *
9
+ * Content class picks the register:
10
+ * - cardinal / aggregate → `formatNumber` (grouped, ≤2 fraction digits)
11
+ * - money → `formatCurrency` (grouped, 2 fraction digits;
12
+ * `fractionDigits: "auto"` elides a whole-value
13
+ * `.00` for summary tiles)
14
+ * - rate → `formatPercent` (value already carries percent
15
+ * units: 94.2 → "94.2%")
16
+ *
17
+ * Not covered (declared exemptions): chart AXIS tick labels, whose
18
+ * density-driven register is owned by `charts/math.ts#formatAxisTick`;
19
+ * app-land `useFormatNumber` / `useFormatCurrency` (i18n package), which
20
+ * bind the active UI language outside the catalog; and machine formats
21
+ * (export/serialization).
22
+ */
23
+
24
+ export type NumberInput = number | string | null | undefined;
25
+
26
+ export interface NumberFormatOptions {
27
+ /** BCP-47 locale forwarded to Intl. Default: system locale. */
28
+ locale?: string;
29
+ /** Pins min and max fraction digits together (fixed-precision columns). */
30
+ precision?: number;
31
+ minimumFractionDigits?: number;
32
+ /** House cap for the cardinal register. Default 2. */
33
+ maximumFractionDigits?: number;
34
+ /** Thousands grouping. Default true. */
35
+ grouping?: boolean;
36
+ }
37
+
38
+ export interface CurrencyFormatOptions extends NumberFormatOptions {
39
+ /** ISO 4217 code, e.g. "USD" — renders through Intl's currency style. */
40
+ currency?: string;
41
+ /**
42
+ * Currency symbol as handed over by Frappe-style adapters. Resolved to an
43
+ * ISO code when known, otherwise prefixed literally.
44
+ */
45
+ symbol?: string;
46
+ /**
47
+ * `"always"` (default) pins 2 fraction digits — the money register that
48
+ * keeps columns aligned. `"auto"` drops them for whole values — the
49
+ * summary-tile register (KPI cards read "$48,250", not "$48,250.00")
50
+ * while still showing cents when a value has them.
51
+ */
52
+ fractionDigits?: "always" | "auto";
53
+ }
54
+
55
+ export type PercentFormatOptions = NumberFormatOptions;
56
+
57
+ /** Frappe adapters hand over a symbol; Intl's currency style wants a code. */
58
+ const CURRENCY_SYMBOL_TO_CODE: Record<string, string> = {
59
+ $: "USD",
60
+ "€": "EUR",
61
+ "£": "GBP",
62
+ "¥": "JPY",
63
+ "₹": "INR",
64
+ "₩": "KRW",
65
+ };
66
+
67
+ /**
68
+ * ISO 4217 code for a currency symbol (or a code passed straight through).
69
+ * `undefined` when the symbol has no known code — callers prefix it.
70
+ */
71
+ export function resolveCurrencyCode(symbol: string | undefined): string | undefined {
72
+ if (!symbol) return undefined;
73
+ if (/^[A-Za-z]{3}$/.test(symbol)) return symbol.toUpperCase();
74
+ return CURRENCY_SYMBOL_TO_CODE[symbol];
75
+ }
76
+
77
+ interface CoercedNumber {
78
+ num: number;
79
+ }
80
+
81
+ function coerceNumberInput(value: NumberInput): CoercedNumber | null {
82
+ if (value == null || value === "") return null;
83
+ if (typeof value === "number") return Number.isFinite(value) ? { num: value } : null;
84
+ const num = Number(value);
85
+ return Number.isFinite(num) ? { num } : null;
86
+ }
87
+
88
+ function resolveDigits(options: NumberFormatOptions): Pick<
89
+ Intl.NumberFormatOptions,
90
+ "minimumFractionDigits" | "maximumFractionDigits"
91
+ > {
92
+ const { precision, minimumFractionDigits, maximumFractionDigits } = options;
93
+ if (precision != null) {
94
+ return { minimumFractionDigits: precision, maximumFractionDigits: precision };
95
+ }
96
+ const max = maximumFractionDigits ?? 2;
97
+ const min = minimumFractionDigits ?? 0;
98
+ return { minimumFractionDigits: min, maximumFractionDigits: Math.max(min, max) };
99
+ }
100
+
101
+ function formatWith(num: number, options: Intl.NumberFormatOptions, locale?: string): string {
102
+ try {
103
+ return new Intl.NumberFormat(locale, options).format(num);
104
+ } catch {
105
+ return String(num);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Cardinal / aggregate register — grouped digits, at most two fraction
111
+ * digits ("15,400", "5,133.33"). Empty input renders "" and an unparseable
112
+ * string passes through unchanged (honest passthrough — never invents).
113
+ */
114
+ export function formatNumber(value: NumberInput, options: NumberFormatOptions = {}): string {
115
+ const coerced = coerceNumberInput(value);
116
+ if (!coerced) return typeof value === "string" ? value : "";
117
+ return formatWith(
118
+ coerced.num,
119
+ { ...resolveDigits(options), useGrouping: options.grouping ?? true },
120
+ options.locale,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Money register — grouped digits with two fraction digits, rendered with
126
+ * the currency's own symbol placement when the code is known ("$15,400.00",
127
+ * "₹15,400.00") and symbol-prefixed otherwise.
128
+ */
129
+ export function formatCurrency(value: NumberInput, options: CurrencyFormatOptions = {}): string {
130
+ const coerced = coerceNumberInput(value);
131
+ if (!coerced) return typeof value === "string" ? value : "";
132
+ const { currency, symbol, fractionDigits = "always", locale, grouping } = options;
133
+ const hasExplicitDigits =
134
+ options.precision != null ||
135
+ options.minimumFractionDigits != null ||
136
+ options.maximumFractionDigits != null;
137
+ const wholeValue = Number.isInteger(coerced.num);
138
+ const digits = hasExplicitDigits
139
+ ? resolveDigits(options)
140
+ : fractionDigits === "auto" && wholeValue
141
+ ? { minimumFractionDigits: 0, maximumFractionDigits: 0 }
142
+ : { minimumFractionDigits: 2, maximumFractionDigits: 2 };
143
+ const code = currency ?? resolveCurrencyCode(symbol);
144
+ const useGrouping = grouping ?? true;
145
+ if (code) {
146
+ return formatWith(coerced.num, { ...digits, useGrouping, style: "currency", currency: code }, locale);
147
+ }
148
+ const amount = formatWith(coerced.num, { ...digits, useGrouping }, locale);
149
+ return symbol ? `${symbol}${amount}` : amount;
150
+ }
151
+
152
+ /**
153
+ * Rate register — the value already carries percent units (94.2 → "94.2%"),
154
+ * never a 0..1 ratio; ratios are converted by the caller that owns the unit.
155
+ */
156
+ export function formatPercent(value: NumberInput, options: PercentFormatOptions = {}): string {
157
+ const formatted = formatNumber(value, options);
158
+ if (formatted === "") return "";
159
+ const coerced = coerceNumberInput(value);
160
+ return coerced ? `${formatted}%` : formatted;
161
+ }
@@ -43,15 +43,16 @@ function resolveTokenValue(
43
43
  }
44
44
 
45
45
  /**
46
- * Readable text/glyph color to sit ON `fillToken` (e.g. "$accentBackground",
47
- * "$color8"): the scheme's paper (`$color1`) or ink (`$color12`) anchor,
48
- * whichever contrasts more on the resolved fill, returned as a concrete value
49
- * so no nested sub-theme can flip it. Returns `undefined` when any color fails
50
- * to resolve/parse — callers keep their own default in that case.
46
+ * Readable text/glyph color to sit ON `fill` (a token like "$accentBackground"
47
+ * / "$color8", or an already-resolved color value — e.g. the step returned by
48
+ * `useAccentTintedSurface`): the scheme's paper (`$color1`) or ink (`$color12`)
49
+ * anchor, whichever contrasts more on the resolved fill, returned as a concrete
50
+ * value so no nested sub-theme can flip it. Returns `undefined` when any color
51
+ * fails to resolve/parse — callers keep their own default in that case.
51
52
  */
52
- export function useReadableTextOn(fillToken: string): string | undefined {
53
+ export function useReadableTextOn(fill_: string | undefined): string | undefined {
53
54
  const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
54
- const fill = resolveTokenValue(theme, fillToken);
55
+ const fill = fill_?.startsWith("$") ? resolveTokenValue(theme, fill_) : fill_;
55
56
  const paper = resolveTokenValue(theme, "color1");
56
57
  const ink = resolveTokenValue(theme, "color12");
57
58
  return useMemo(() => {
@@ -99,3 +100,43 @@ export function useAccentOnSurface(): string {
99
100
  return best ?? ink;
100
101
  }, [c1, c2, ink, steps.join("|")]);
101
102
  }
103
+
104
+ /**
105
+ * Accent-TINTED surface step — the accent-ramp analogue of a neutral surface
106
+ * tier, for selected/current chips that carry the accent language at surface
107
+ * strength rather than as a solid fill (LC-05: selected state is accent; the
108
+ * ViewSwitcher rider pins segmented-control strength at "tinted", not solid).
109
+ *
110
+ * Picks the accent step whose relative luminance sits closest to the neutral
111
+ * tier it replaces, so the chip stays a surface in BOTH schemes without a
112
+ * per-component light/dark fork: against this repo's stock accent ramp that
113
+ * resolves to a pale violet in light and a deep violet in dark, because the
114
+ * accent ramp flips with the scheme exactly as the neutral ramp does.
115
+ *
116
+ * Returns `undefined` when the accent ramp is absent or unparseable — callers
117
+ * keep their neutral default rather than render an unthemed chip.
118
+ */
119
+ export function useAccentTintedSurface(neutralToken = "$color4"): string | undefined {
120
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
121
+ const neutral = resolveTokenValue(theme, neutralToken);
122
+ const steps: (string | undefined)[] = [];
123
+ for (let i = 1; i <= 12; i++) steps.push(resolveTokenValue(theme, `accent${i}`));
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ return useMemo(() => {
126
+ const neutralHex = neutral ? normalizeToHex(neutral) : null;
127
+ if (!neutralHex) return undefined;
128
+ const target = relativeLuminance(neutralHex);
129
+ let best: string | undefined;
130
+ let bestDelta = Number.POSITIVE_INFINITY;
131
+ for (const step of steps) {
132
+ const hex = step ? normalizeToHex(step) : null;
133
+ if (!step || !hex) continue;
134
+ const delta = Math.abs(relativeLuminance(hex) - target);
135
+ if (delta < bestDelta) {
136
+ bestDelta = delta;
137
+ best = step;
138
+ }
139
+ }
140
+ return best;
141
+ }, [neutral, steps.join("|")]);
142
+ }