@nim.zone/ui 0.6.0

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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -0
  3. package/dist/components/admin-shell.d.ts +121 -0
  4. package/dist/components/app-shell.d.ts +23 -0
  5. package/dist/components/auth-screen.d.ts +33 -0
  6. package/dist/components/avatar.d.ts +10 -0
  7. package/dist/components/badge.d.ts +14 -0
  8. package/dist/components/banner.d.ts +11 -0
  9. package/dist/components/breadcrumb.d.ts +12 -0
  10. package/dist/components/button.d.ts +21 -0
  11. package/dist/components/card.d.ts +15 -0
  12. package/dist/components/chat-composer.d.ts +52 -0
  13. package/dist/components/chat.d.ts +77 -0
  14. package/dist/components/checkout.d.ts +79 -0
  15. package/dist/components/choice.d.ts +15 -0
  16. package/dist/components/combobox.d.ts +31 -0
  17. package/dist/components/date-field.d.ts +58 -0
  18. package/dist/components/dialog.d.ts +20 -0
  19. package/dist/components/empty-state.d.ts +9 -0
  20. package/dist/components/feedback.d.ts +18 -0
  21. package/dist/components/field.d.ts +47 -0
  22. package/dist/components/icon-button.d.ts +12 -0
  23. package/dist/components/icon.d.ts +72 -0
  24. package/dist/components/layout.d.ts +13 -0
  25. package/dist/components/list.d.ts +16 -0
  26. package/dist/components/menu.d.ts +56 -0
  27. package/dist/components/onboarding.d.ts +50 -0
  28. package/dist/components/otp-input.d.ts +29 -0
  29. package/dist/components/pagination.d.ts +12 -0
  30. package/dist/components/password-field.d.ts +33 -0
  31. package/dist/components/phone-field.d.ts +44 -0
  32. package/dist/components/plan-card.d.ts +45 -0
  33. package/dist/components/plan-picker.d.ts +52 -0
  34. package/dist/components/profile-header.d.ts +47 -0
  35. package/dist/components/profile-screen.d.ts +44 -0
  36. package/dist/components/resource-meter.d.ts +22 -0
  37. package/dist/components/section-header.d.ts +8 -0
  38. package/dist/components/segmented.d.ts +18 -0
  39. package/dist/components/sheet.d.ts +18 -0
  40. package/dist/components/sign-in-flow.d.ts +60 -0
  41. package/dist/components/slider.d.ts +10 -0
  42. package/dist/components/stat.d.ts +10 -0
  43. package/dist/components/stepper.d.ts +17 -0
  44. package/dist/components/tab-bar.d.ts +40 -0
  45. package/dist/components/table.d.ts +33 -0
  46. package/dist/components/tabs.d.ts +23 -0
  47. package/dist/components/task-progress.d.ts +38 -0
  48. package/dist/components/theme.d.ts +49 -0
  49. package/dist/components/toast.d.ts +17 -0
  50. package/dist/components/tooltip.d.ts +18 -0
  51. package/dist/components/typography.d.ts +18 -0
  52. package/dist/components/wizard.d.ts +70 -0
  53. package/dist/index.d.ts +104 -0
  54. package/dist/lib/calendars.d.ts +81 -0
  55. package/dist/lib/cn.d.ts +7 -0
  56. package/dist/lib/countries.d.ts +33 -0
  57. package/dist/lib/use-anchor.d.ts +22 -0
  58. package/dist/nim.css +1 -0
  59. package/dist/nim.js +3266 -0
  60. package/package.json +83 -0
  61. package/src/components/admin-shell.tsx +338 -0
  62. package/src/components/app-shell.tsx +36 -0
  63. package/src/components/auth-screen.tsx +71 -0
  64. package/src/components/avatar.tsx +32 -0
  65. package/src/components/badge.tsx +44 -0
  66. package/src/components/banner.tsx +47 -0
  67. package/src/components/breadcrumb.tsx +45 -0
  68. package/src/components/button.tsx +64 -0
  69. package/src/components/card.tsx +46 -0
  70. package/src/components/chat-composer.tsx +331 -0
  71. package/src/components/chat.tsx +352 -0
  72. package/src/components/checkout.tsx +173 -0
  73. package/src/components/choice.tsx +46 -0
  74. package/src/components/combobox.tsx +157 -0
  75. package/src/components/date-field.tsx +394 -0
  76. package/src/components/dialog.tsx +77 -0
  77. package/src/components/empty-state.tsx +23 -0
  78. package/src/components/feedback.tsx +61 -0
  79. package/src/components/field.tsx +177 -0
  80. package/src/components/icon-button.tsx +35 -0
  81. package/src/components/icon.tsx +167 -0
  82. package/src/components/layout.tsx +38 -0
  83. package/src/components/list.tsx +78 -0
  84. package/src/components/menu.tsx +172 -0
  85. package/src/components/onboarding.tsx +157 -0
  86. package/src/components/otp-input.tsx +144 -0
  87. package/src/components/pagination.tsx +86 -0
  88. package/src/components/password-field.tsx +112 -0
  89. package/src/components/phone-field.tsx +242 -0
  90. package/src/components/plan-card.tsx +124 -0
  91. package/src/components/plan-picker.tsx +144 -0
  92. package/src/components/profile-header.tsx +134 -0
  93. package/src/components/profile-screen.tsx +104 -0
  94. package/src/components/resource-meter.tsx +60 -0
  95. package/src/components/section-header.tsx +31 -0
  96. package/src/components/segmented.tsx +53 -0
  97. package/src/components/sheet.tsx +74 -0
  98. package/src/components/sign-in-flow.tsx +322 -0
  99. package/src/components/slider.tsx +50 -0
  100. package/src/components/stat.tsx +30 -0
  101. package/src/components/stepper.tsx +77 -0
  102. package/src/components/tab-bar.tsx +82 -0
  103. package/src/components/table.tsx +81 -0
  104. package/src/components/tabs.tsx +72 -0
  105. package/src/components/task-progress.tsx +111 -0
  106. package/src/components/theme.tsx +112 -0
  107. package/src/components/toast.tsx +87 -0
  108. package/src/components/tooltip.tsx +30 -0
  109. package/src/components/typography.tsx +65 -0
  110. package/src/components/wizard.tsx +192 -0
  111. package/src/index.ts +216 -0
  112. package/src/lib/calendars.ts +227 -0
  113. package/src/lib/cn.ts +8 -0
  114. package/src/lib/countries.ts +110 -0
  115. package/src/lib/use-anchor.ts +92 -0
  116. package/src/theme/colorways/coral.css +62 -0
  117. package/src/theme/colorways/oxblood.css +23 -0
  118. package/src/theme/colorways/paper.css +68 -0
  119. package/src/theme/colorways/teal.css +56 -0
  120. package/src/theme/colorways/vermilion.css +21 -0
  121. package/src/theme/components.css +4606 -0
  122. package/src/theme/contract.css +204 -0
  123. package/src/theme/fonts.css +67 -0
  124. package/src/theme/index.css +33 -0
  125. package/src/theme/persian.css +47 -0
  126. package/src/theme/reset.css +118 -0
  127. package/src/theme/styles/ledger.css +106 -0
  128. package/src/theme/styles/vlora.css +91 -0
  129. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Calendar arithmetic for the two systems the kit draws: Gregorian and
3
+ * Jalali (Solar Hijri).
4
+ *
5
+ * There is no conversion table and no leap-year rule in this file, because the
6
+ * platform already ships one: `Intl.DateTimeFormat` with `-u-ca-persian` is
7
+ * ICU's Persian calendar, and it is the same implementation a browser uses to
8
+ * label a date anywhere else. So the direction that is hard — Gregorian to
9
+ * Jalali — is asked of `Intl`, and the direction that is easy — Jalali back to
10
+ * Gregorian — is a close estimate corrected against that same answer until it
11
+ * round-trips. Every value this module returns has therefore been checked
12
+ * against the platform's own calendar rather than against a table that has to
13
+ * be maintained here and goes wrong in 1403 or 2049.
14
+ *
15
+ * The kit's value type never changes: an `IsoDate` is always the Gregorian
16
+ * `YYYY-MM-DD`, in every calendar system. What the viewer reads and what the
17
+ * API receives are different questions, and only the first one has a calendar.
18
+ */
19
+
20
+ /** ISO `YYYY-MM-DD`, Gregorian, always. The kit never invents a date type. */
21
+ export type IsoDate = string
22
+
23
+ /** The calendar a grid is drawn in — never what a value is stored in. */
24
+ export type CalendarSystem = 'gregory' | 'persian'
25
+
26
+ export interface CalendarParts {
27
+ day: number
28
+ month: number
29
+ year: number
30
+ }
31
+
32
+ const DAY_MS = 86_400_000
33
+
34
+ /** 1 Farvardin 1 — the Solar Hijri epoch, as a Gregorian UTC timestamp. */
35
+ const PERSIAN_EPOCH_MS = Date.UTC(622, 2, 22)
36
+ const PERSIAN_YEAR_DAYS = 365.2422
37
+
38
+ export const isoOf = (date: Date): IsoDate => date.toISOString().slice(0, 10)
39
+ export const dateOf = (value: IsoDate): Date => new Date(`${value}T00:00:00Z`)
40
+ export const todayIso = (): IsoDate => isoOf(new Date())
41
+
42
+ /**
43
+ * Latin digits and the Persian calendar, regardless of the interface locale:
44
+ * this formatter is read by the code, not by a person. What the viewer sees is
45
+ * formatted separately, in their own locale.
46
+ */
47
+ const PERSIAN_PARTS = new Intl.DateTimeFormat('en-u-ca-persian-nu-latn', {
48
+ day: 'numeric',
49
+ month: 'numeric',
50
+ timeZone: 'UTC',
51
+ year: 'numeric',
52
+ })
53
+
54
+ /** The calendar fields a viewer of `system` would read off this instant. */
55
+ export function partsOf(value: IsoDate, system: CalendarSystem): CalendarParts {
56
+ const date = dateOf(value)
57
+ if (system === 'gregory') {
58
+ return { day: date.getUTCDate(), month: date.getUTCMonth() + 1, year: date.getUTCFullYear() }
59
+ }
60
+
61
+ const parts = PERSIAN_PARTS.formatToParts(date)
62
+ const field = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? '0')
63
+ // `year` comes back as an era year; the Persian calendar has one era in use,
64
+ // so it is the year. `relatedYear` would be the Gregorian one — not wanted.
65
+ return { day: field('day'), month: field('month'), year: field('year') }
66
+ }
67
+
68
+ const rank = (parts: CalendarParts) => parts.year * 10_000 + parts.month * 100 + parts.day
69
+
70
+ /**
71
+ * The Gregorian date on which `parts` falls in `system`.
72
+ *
73
+ * For Jalali this starts from the mean-year estimate and walks to the answer,
74
+ * comparing against `partsOf` — which is ICU. The estimate is never more than
75
+ * a few days out, and the loop is bounded, so a bad input fails by returning
76
+ * its best effort rather than by spinning.
77
+ */
78
+ export function fromParts(parts: CalendarParts, system: CalendarSystem): IsoDate {
79
+ if (system === 'gregory') {
80
+ return isoOf(new Date(Date.UTC(parts.year, parts.month - 1, parts.day)))
81
+ }
82
+
83
+ const estimateDays =
84
+ Math.floor((parts.year - 1) * PERSIAN_YEAR_DAYS) +
85
+ (parts.month <= 7 ? (parts.month - 1) * 31 : 186 + (parts.month - 7) * 30) +
86
+ parts.day -
87
+ 1
88
+ let cursor = new Date(PERSIAN_EPOCH_MS + estimateDays * DAY_MS)
89
+ const target = rank(parts)
90
+
91
+ for (let step = 0; step < 40; step += 1) {
92
+ const current = partsOf(isoOf(cursor), 'persian')
93
+ const currentRank = rank(current)
94
+ if (currentRank === target) break
95
+ // Convert the disagreement into a day count: years and months are worth
96
+ // roughly what they are worth, and the remainder is walked one day at a
97
+ // time. In practice this lands on the second pass.
98
+ const drift =
99
+ (parts.year - current.year) * 365 +
100
+ (parts.month - current.month) * 30 +
101
+ (parts.day - current.day)
102
+ cursor = new Date(cursor.getTime() + (drift === 0 ? (currentRank < target ? 1 : -1) : drift) * DAY_MS)
103
+ }
104
+
105
+ return isoOf(cursor)
106
+ }
107
+
108
+ /** The first day of the month `value` falls in, in `system`. */
109
+ export function startOfMonth(value: IsoDate, system: CalendarSystem): IsoDate {
110
+ const parts = partsOf(value, system)
111
+ return fromParts({ ...parts, day: 1 }, system)
112
+ }
113
+
114
+ /** `delta` months on from `value`, clamped into the target month's length. */
115
+ export function addMonths(value: IsoDate, delta: number, system: CalendarSystem): IsoDate {
116
+ const parts = partsOf(value, system)
117
+ const zero = parts.year * 12 + (parts.month - 1) + delta
118
+ const year = Math.floor(zero / 12)
119
+ const month = (zero % 12) + 1
120
+ const length = monthLength(year, month, system)
121
+ return fromParts({ day: Math.min(parts.day, length), month, year }, system)
122
+ }
123
+
124
+ /**
125
+ * How many days that month holds — measured, not tabulated. The distance to
126
+ * the first of the next month is the length, whatever the leap rule says, so
127
+ * an Esfand of 30 days needs no special case here.
128
+ */
129
+ export function monthLength(year: number, month: number, system: CalendarSystem): number {
130
+ const first = dateOf(fromParts({ day: 1, month, year }, system)).getTime()
131
+ const nextMonth = month === 12 ? 1 : month + 1
132
+ const nextYear = month === 12 ? year + 1 : year
133
+ const next = dateOf(fromParts({ day: 1, month: nextMonth, year: nextYear }, system)).getTime()
134
+ return Math.round((next - first) / DAY_MS)
135
+ }
136
+
137
+ export const addDays = (value: IsoDate, days: number): IsoDate =>
138
+ isoOf(new Date(dateOf(value).getTime() + days * DAY_MS))
139
+
140
+ /** 0 = Sunday. The week's shape is the same fact in every calendar. */
141
+ export const weekdayOf = (value: IsoDate): number => dateOf(value).getUTCDay()
142
+
143
+ /**
144
+ * The locale tag that puts `Intl` into `system`. A `-u-ca-` extension already
145
+ * on the tag wins, so an app asking for `fa-IR-u-ca-gregory` keeps it.
146
+ */
147
+ export function localeFor(locale: string | undefined, system: CalendarSystem): string {
148
+ const base = locale ?? 'en'
149
+ if (base.includes('-u-ca-') || base.includes('-u-')) return base
150
+ return `${base}-u-ca-${system}`
151
+ }
152
+
153
+ /**
154
+ * The calendar an `fa` reader expects. Persian interfaces run on the Jalali
155
+ * calendar; everything else this kit ships in runs on the Gregorian one.
156
+ */
157
+ export const defaultSystem = (locale: string | undefined): CalendarSystem =>
158
+ locale?.startsWith('fa') ? 'persian' : 'gregory'
159
+
160
+ /** Persian weeks begin on Saturday; the Gregorian ones here on Monday. */
161
+ export const defaultWeekStart = (system: CalendarSystem): number => (system === 'persian' ? 6 : 1)
162
+
163
+ const DIGIT_CACHE = new Map<string, string[]>()
164
+
165
+ /** The locale's own ten digits, so `1404` can be written as `۱۴۰۴`. */
166
+ function digitsOf(locale: string | undefined): string[] {
167
+ const key = locale ?? 'en'
168
+ const cached = DIGIT_CACHE.get(key)
169
+ if (cached) return cached
170
+ const format = new Intl.NumberFormat(key, { useGrouping: false })
171
+ const digits = Array.from({ length: 10 }, (_, digit) => format.format(digit))
172
+ DIGIT_CACHE.set(key, digits)
173
+ return digits
174
+ }
175
+
176
+ /**
177
+ * `۱۴۰۴/۰۶/۰۱` — year first, zero-padded, in the locale's digits.
178
+ *
179
+ * Deliberately not `Intl`'s numeric pattern, which would give `۰۱/۰۶/۱۴۰۴ ه.ش`
180
+ * under an English locale on the Jalali calendar: field order and an era
181
+ * suffix that no Iranian writes and this module's parser cannot read back. A
182
+ * Jalali date is written biggest-unit-first everywhere it is written at all,
183
+ * so `formatNumeric` and `parseNumeric` agree by construction.
184
+ */
185
+ export function formatNumeric(value: IsoDate, locale: string | undefined, system: CalendarSystem): string {
186
+ const parts = partsOf(value, system)
187
+ const digits = digitsOf(locale)
188
+ const write = (number: number, width = 1) =>
189
+ String(number)
190
+ .padStart(width, '0')
191
+ .replace(/\d/g, (digit) => digits[Number(digit)])
192
+ return `${write(parts.year)}/${write(parts.month, 2)}/${write(parts.day, 2)}`
193
+ }
194
+
195
+ /**
196
+ * Reads `1404/06/01`, `۱۴۰۴-۰۶-۰۱`, or anything else with three numbers in
197
+ * year, month, day order. Returns null rather than guessing at two.
198
+ */
199
+ export function parseNumeric(input: string, system: CalendarSystem): IsoDate | null {
200
+ const ascii = toAsciiDigits(input)
201
+ const numbers = ascii.match(/\d+/g)
202
+ if (!numbers || numbers.length < 3) return null
203
+ const [year, month, day] = numbers.map(Number)
204
+ if (month < 1 || month > 12 || day < 1) return null
205
+ if (day > monthLength(year, month, system)) return null
206
+ const value = fromParts({ day, month, year }, system)
207
+ // The round trip is the validation: anything ICU disagrees with is rejected
208
+ // rather than silently snapped to a neighbouring day.
209
+ const back = partsOf(value, system)
210
+ return back.year === year && back.month === month && back.day === day ? value : null
211
+ }
212
+
213
+ /**
214
+ * Persian (۰–۹) and Arabic-Indic (٠–٩) digits to ASCII, separators kept — a
215
+ * Persian keyboard types ۱۴۰۴/۰۶/۰۱ and the parser above wants 1404/06/01.
216
+ * `lib/countries.ts` has the digits-only twin, which phone entry needs.
217
+ */
218
+ function toAsciiDigits(input: string): string {
219
+ let output = ''
220
+ for (const character of input) {
221
+ const code = character.codePointAt(0) ?? 0
222
+ if (code >= 0x06f0 && code <= 0x06f9) output += String.fromCodePoint(code - 0x06f0 + 0x30)
223
+ else if (code >= 0x0660 && code <= 0x0669) output += String.fromCodePoint(code - 0x0660 + 0x30)
224
+ else output += character
225
+ }
226
+ return output
227
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The kit's only class-name helper. nim components compose a small, fixed set
3
+ * of semantic class names, so a full `clsx`-style dependency would buy nothing
4
+ * that this does not already do.
5
+ */
6
+ export type ClassValue = string | false | null | undefined
7
+
8
+ export const cn = (...values: ClassValue[]) => values.filter(Boolean).join(' ')
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Dialling codes for every ISO 3166-1 country and territory.
3
+ *
4
+ * The table carries the two things a name cannot be derived from — the ISO
5
+ * code and the calling code — and nothing else. The display name comes from
6
+ * `Intl.DisplayNames` in the viewer's own locale, so a Persian interface lists
7
+ * «آلمان» and an English one lists "Germany" without a second column to keep
8
+ * in sync, and the flag is derived from the ISO code's regional indicators
9
+ * rather than shipped as 250 images.
10
+ */
11
+
12
+ /** `ISO2:dialling code`, packed so the source stays reviewable in one screen. */
13
+ const TABLE =
14
+ 'AD:376 AE:971 AF:93 AG:1268 AI:1264 AL:355 AM:374 AO:244 AQ:672 AR:54 AS:1684 AT:43 AU:61 ' +
15
+ 'AW:297 AX:358 AZ:994 BA:387 BB:1246 BD:880 BE:32 BF:226 BG:359 BH:973 BI:257 BJ:229 BL:590 ' +
16
+ 'BM:1441 BN:673 BO:591 BQ:599 BR:55 BS:1242 BT:975 BW:267 BY:375 BZ:501 CA:1 CC:61 CD:243 ' +
17
+ 'CF:236 CG:242 CH:41 CI:225 CK:682 CL:56 CM:237 CN:86 CO:57 CR:506 CU:53 CV:238 CW:599 CX:61 ' +
18
+ 'CY:357 CZ:420 DE:49 DJ:253 DK:45 DM:1767 DO:1809 DZ:213 EC:593 EE:372 EG:20 EH:212 ER:291 ' +
19
+ 'ES:34 ET:251 FI:358 FJ:679 FK:500 FM:691 FO:298 FR:33 GA:241 GB:44 GD:1473 GE:995 GF:594 ' +
20
+ 'GG:44 GH:233 GI:350 GL:299 GM:220 GN:224 GP:590 GQ:240 GR:30 GT:502 GU:1671 GW:245 GY:592 ' +
21
+ 'HK:852 HN:504 HR:385 HT:509 HU:36 ID:62 IE:353 IL:972 IM:44 IN:91 IO:246 IQ:964 IR:98 IS:354 ' +
22
+ 'IT:39 JE:44 JM:1876 JO:962 JP:81 KE:254 KG:996 KH:855 KI:686 KM:269 KN:1869 KP:850 KR:82 ' +
23
+ 'KW:965 KY:1345 KZ:7 LA:856 LB:961 LC:1758 LI:423 LK:94 LR:231 LS:266 LT:370 LU:352 LV:371 ' +
24
+ 'LY:218 MA:212 MC:377 MD:373 ME:382 MF:590 MG:261 MH:692 MK:389 ML:223 MM:95 MN:976 MO:853 ' +
25
+ 'MP:1670 MQ:596 MR:222 MS:1664 MT:356 MU:230 MV:960 MW:265 MX:52 MY:60 MZ:258 NA:264 NC:687 ' +
26
+ 'NE:227 NF:672 NG:234 NI:505 NL:31 NO:47 NP:977 NR:674 NU:683 NZ:64 OM:968 PA:507 PE:51 ' +
27
+ 'PF:689 PG:675 PH:63 PK:92 PL:48 PM:508 PR:1787 PS:970 PT:351 PW:680 PY:595 QA:974 RE:262 ' +
28
+ 'RO:40 RS:381 RU:7 RW:250 SA:966 SB:677 SC:248 SD:249 SE:46 SG:65 SH:290 SI:386 SJ:47 SK:421 ' +
29
+ 'SL:232 SM:378 SN:221 SO:252 SR:597 SS:211 ST:239 SV:503 SX:1721 SY:963 SZ:268 TC:1649 ' +
30
+ 'TD:235 TG:228 TH:66 TJ:992 TK:690 TL:670 TM:993 TN:216 TO:676 TR:90 TT:1868 TV:688 TW:886 ' +
31
+ 'TZ:255 UA:380 UG:256 US:1 UY:598 UZ:998 VA:39 VC:1784 VE:58 VG:1284 VI:1340 VN:84 VU:678 ' +
32
+ 'WF:681 WS:685 YE:967 YT:262 ZA:27 ZM:260 ZW:263'
33
+
34
+ export interface Country {
35
+ /** Calling code without the plus: `98`, `1`, `44`. */
36
+ dial: string
37
+ /** Regional-indicator flag, derived from the ISO code. */
38
+ flag: string
39
+ /** ISO 3166-1 alpha-2, uppercase. */
40
+ iso2: string
41
+ }
42
+
43
+ /** 🇮🇷 from `IR`: two regional indicator symbols, no image asset involved. */
44
+ function flagOf(iso2: string): string {
45
+ return String.fromCodePoint(...[...iso2].map((letter) => 0x1f1e6 + letter.charCodeAt(0) - 65))
46
+ }
47
+
48
+ export const COUNTRIES: Country[] = TABLE.split(' ').map((entry) => {
49
+ const [iso2, dial] = entry.split(':')
50
+ return { dial, flag: flagOf(iso2), iso2 }
51
+ })
52
+
53
+ const BY_ISO2 = new Map(COUNTRIES.map((country) => [country.iso2, country]))
54
+
55
+ export function countryByIso2(iso2: string): Country | undefined {
56
+ return BY_ISO2.get(iso2.toUpperCase())
57
+ }
58
+
59
+ /**
60
+ * The country a `+…` number belongs to. Longest code wins, so `+1268`
61
+ * (Antigua) is not read as `+1` (United States).
62
+ */
63
+ export function countryByDial(e164: string): Country | undefined {
64
+ const digits = e164.replace(/\D/g, '')
65
+ let match: Country | undefined
66
+ for (const country of COUNTRIES) {
67
+ if (!digits.startsWith(country.dial)) continue
68
+ if (!match || country.dial.length > match.dial.length) match = country
69
+ }
70
+ return match
71
+ }
72
+
73
+ /**
74
+ * Country names in `locale`, memoised per locale — building an
75
+ * `Intl.DisplayNames` is not free and a picker asks for every name on each
76
+ * keystroke.
77
+ */
78
+ const NAME_CACHE = new Map<string, (iso2: string) => string>()
79
+
80
+ export function countryNamer(locale: string): (iso2: string) => string {
81
+ const cached = NAME_CACHE.get(locale)
82
+ if (cached) return cached
83
+
84
+ let lookup: (iso2: string) => string
85
+ try {
86
+ const display = new Intl.DisplayNames([locale], { type: 'region' })
87
+ lookup = (iso2) => display.of(iso2) ?? iso2
88
+ } catch {
89
+ lookup = (iso2) => iso2
90
+ }
91
+ NAME_CACHE.set(locale, lookup)
92
+ return lookup
93
+ }
94
+
95
+ /**
96
+ * Persian (۰–۹) and Arabic-Indic (٠–٩) digits to ASCII, everything else
97
+ * dropped. Every phone and code field in the kit runs input through this: a
98
+ * Persian keyboard types ۰۹۱۲…, and a number the viewer can read has to reach
99
+ * the API as `+98912…`.
100
+ */
101
+ export function toAsciiDigits(input: string): string {
102
+ let output = ''
103
+ for (const character of input) {
104
+ const code = character.codePointAt(0) ?? 0
105
+ if (code >= 0x06f0 && code <= 0x06f9) output += String.fromCodePoint(code - 0x06f0 + 0x30)
106
+ else if (code >= 0x0660 && code <= 0x0669) output += String.fromCodePoint(code - 0x0660 + 0x30)
107
+ else if (character >= '0' && character <= '9') output += character
108
+ }
109
+ return output
110
+ }
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { RefObject } from 'react'
3
+
4
+ export interface AnchorPosition {
5
+ left: number
6
+ top: number
7
+ }
8
+
9
+ export interface UseAnchorOptions {
10
+ /** Called when the viewer asks to dismiss: Escape, or a click outside. */
11
+ onDismiss: () => void
12
+ open: boolean
13
+ }
14
+
15
+ /**
16
+ * Anchored overlay placement, shared by Menu and Popover.
17
+ *
18
+ * Deliberately small: nim does not ship a floating-element engine. It places
19
+ * the panel under the trigger, flips it above when the space below cannot hold
20
+ * it, and clamps it inside the viewport. Anything needing collision detection
21
+ * against scroll containers wants a real positioning library, not this.
22
+ *
23
+ * It also owns the two dismissals every overlay must honour and hand-rolled
24
+ * ones always forget: Escape, and a pointer landing outside both elements.
25
+ */
26
+ export function useAnchor<T extends HTMLElement, P extends HTMLElement>(
27
+ triggerRef: RefObject<T | null>,
28
+ panelRef: RefObject<P | null>,
29
+ { onDismiss, open }: UseAnchorOptions,
30
+ ) {
31
+ const [position, setPosition] = useState<AnchorPosition>({ left: 0, top: 0 })
32
+ const restoreRef = useRef<HTMLElement | null>(null)
33
+
34
+ const place = useCallback(() => {
35
+ const trigger = triggerRef.current
36
+ const panel = panelRef.current
37
+ if (!trigger || !panel) return
38
+
39
+ const anchor = trigger.getBoundingClientRect()
40
+ const { height, width } = panel.getBoundingClientRect()
41
+ const gap = 4
42
+ const margin = 8
43
+ const rtl = getComputedStyle(trigger).direction === 'rtl'
44
+
45
+ const below = anchor.bottom + gap
46
+ const flip = below + height > window.innerHeight && anchor.top - gap - height > 0
47
+ const top = flip ? anchor.top - gap - height : below
48
+
49
+ const preferred = rtl ? anchor.right - width : anchor.left
50
+ const left = Math.min(Math.max(preferred, margin), window.innerWidth - width - margin)
51
+
52
+ setPosition({ left, top })
53
+ }, [panelRef, triggerRef])
54
+
55
+ useLayoutEffect(() => {
56
+ if (!open) return
57
+ place()
58
+ }, [open, place])
59
+
60
+ useEffect(() => {
61
+ if (!open) return
62
+
63
+ restoreRef.current = document.activeElement as HTMLElement | null
64
+
65
+ const onKeyDown = (event: KeyboardEvent) => {
66
+ if (event.key === 'Escape') {
67
+ event.stopPropagation()
68
+ onDismiss()
69
+ }
70
+ }
71
+ const onPointerDown = (event: PointerEvent) => {
72
+ const target = event.target as Node
73
+ if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return
74
+ onDismiss()
75
+ }
76
+
77
+ window.addEventListener('keydown', onKeyDown)
78
+ window.addEventListener('pointerdown', onPointerDown)
79
+ window.addEventListener('resize', place)
80
+ window.addEventListener('scroll', place, true)
81
+
82
+ return () => {
83
+ window.removeEventListener('keydown', onKeyDown)
84
+ window.removeEventListener('pointerdown', onPointerDown)
85
+ window.removeEventListener('resize', place)
86
+ window.removeEventListener('scroll', place, true)
87
+ restoreRef.current?.focus?.()
88
+ }
89
+ }, [onDismiss, open, panelRef, place, triggerRef])
90
+
91
+ return position
92
+ }
@@ -0,0 +1,62 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ COLOURWAY · CORAL — warm cream, coral signal
3
+
4
+ Vlora's palette, carried over from `vlora-app/src/theme/tokens.css` so an
5
+ app on it keeps its exact appearance. Its neutral hierarchy is inverted from
6
+ the paper set's: the canvas is the tinted warm off-white and surfaces are
7
+ pure white, which is what makes cards read as raised without a heavy border.
8
+
9
+ Selected with `data-nim-colorway="coral"`.
10
+ ═══════════════════════════════════════════════════════════════════════════ */
11
+
12
+ [data-nim-colorway='coral'] {
13
+ /* Brand anchors, kept as their own names so the colourway stays readable
14
+ against the original Vlora token file. */
15
+ --vlora-coral: #d97757;
16
+ --vlora-crail: #c15f3c;
17
+ --vlora-cloudy: #b0aea5;
18
+ --vlora-slate: #131314;
19
+
20
+ --nim-canvas: light-dark(#faf9f6, #1e1d1b);
21
+ --nim-canvas-sunken: light-dark(#f5f4f0, #131314);
22
+ --nim-surface: light-dark(#ffffff, #2e2d2b);
23
+ --nim-surface-raised: light-dark(#ffffff, #43413e);
24
+ /* Was equal to `surface` in dark, which left a hovered row and a muted card
25
+ indistinguishable from the card under them. */
26
+ --nim-surface-muted: light-dark(#f5f4f0, #383634);
27
+ --nim-scrim: light-dark(rgba(19, 19, 20, 0.52), rgba(19, 19, 20, 0.64));
28
+
29
+ --nim-ink: light-dark(#131314, #f4f3ee);
30
+ --nim-ink-secondary: light-dark(#6b6965, #d4d2ca);
31
+ --nim-ink-tertiary: light-dark(#b0aea5, #8a8880);
32
+ --nim-ink-inverse: light-dark(#faf9f5, #131314);
33
+ --nim-on-accent: light-dark(#ffffff, #ffffff);
34
+
35
+ /* Structure here comes from surface and shadow, so borders stay quiet —
36
+ quieter than the paper set's, by design. */
37
+ --nim-line: light-dark(rgba(19, 19, 20, 0.1), rgba(255, 255, 255, 0.16));
38
+ --nim-line-soft: light-dark(rgba(19, 19, 20, 0.06), rgba(255, 255, 255, 0.1));
39
+ --nim-line-strong: light-dark(rgba(19, 19, 20, 0.18), rgba(255, 255, 255, 0.3));
40
+
41
+ --nim-accent: light-dark(#d97757, #e0855f);
42
+ --nim-accent-hover: light-dark(#cf6a49, #e8906b);
43
+ --nim-accent-strong: light-dark(#c15f3c, #d97757);
44
+ --nim-accent-soft: light-dark(rgba(217, 119, 87, 0.12), rgba(224, 133, 95, 0.15));
45
+ --nim-accent-line: light-dark(rgba(217, 119, 87, 0.24), rgba(224, 133, 95, 0.28));
46
+
47
+ --nim-success: light-dark(#2f8f5b, #6cc292);
48
+ --nim-success-soft: light-dark(rgba(47, 143, 91, 0.12), rgba(108, 194, 146, 0.14));
49
+ --nim-warning: light-dark(#b07a1f, #d9a94f);
50
+ --nim-warning-soft: light-dark(rgba(215, 154, 43, 0.14), rgba(217, 169, 79, 0.14));
51
+ --nim-danger: light-dark(#c0492f, #e0796a);
52
+ --nim-danger-hover: light-dark(#ab4029, #e88a7c);
53
+ --nim-danger-soft: light-dark(rgba(216, 92, 69, 0.12), rgba(224, 121, 106, 0.14));
54
+ --nim-info: light-dark(#4a6b80, #90b2c6);
55
+ --nim-info-soft: light-dark(rgba(74, 107, 128, 0.1), rgba(144, 178, 198, 0.12));
56
+
57
+ /* A soft shadow tinted at 36% of a colour barely darker than the canvas did
58
+ nothing at all in dark. Dark elevation is true black, and deeper. */
59
+ --nim-shadow-tint-sm: light-dark(rgba(19, 19, 20, 0.05), rgba(0, 0, 0, 0.44));
60
+ --nim-shadow-tint-md: light-dark(rgba(19, 19, 20, 0.08), rgba(0, 0, 0, 0.52));
61
+ --nim-shadow-tint-lg: light-dark(rgba(19, 19, 20, 0.1), rgba(0, 0, 0, 0.6));
62
+ }
@@ -0,0 +1,23 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ COLOURWAY · OXBLOOD — the same paper, dressed for law
3
+
4
+ Deeper than vermilion and shifted toward wine: the wax-seal red of legal
5
+ tradition. Darker, cooler and lower in chroma, it reads as institutional
6
+ permanence rather than editorial urgency, while keeping the same role.
7
+
8
+ Six declarations, because that is genuinely all that separates it from
9
+ `vermilion` — the neutral half comes from `paper.css`. Before 0.2 this was
10
+ a 220-line theme file that duplicated ninety-eight identical tokens to
11
+ change these six.
12
+
13
+ Selected with `data-nim-colorway="oxblood"`.
14
+ ═══════════════════════════════════════════════════════════════════════════ */
15
+
16
+ [data-nim-colorway='oxblood'] {
17
+ --nim-accent: light-dark(#6b1f2a, #d38691);
18
+ --nim-accent-hover: light-dark(#5e1a25, #d9949e);
19
+ --nim-accent-strong: light-dark(#551722, #dfa3ac);
20
+ --nim-accent-soft: light-dark(#f2e4e4, rgba(211, 134, 145, 0.16));
21
+ --nim-accent-line: light-dark(rgba(107, 31, 42, 0.34), rgba(211, 134, 145, 0.38));
22
+ --nim-on-accent: light-dark(#fdf7f5, #17130f);
23
+ }
@@ -0,0 +1,68 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ NEUTRALS · PAPER — shared by the `vermilion` and `oxblood` colourways
3
+
4
+ A colourway answers the whole colour contract: surfaces, ink, lines, accent,
5
+ status, and the tint its style's shadows are drawn in. Two of them answer
6
+ the neutral half identically — warm paper and dense warm ink — and differ
7
+ only in the signal. Rather than copy ninety-odd values to change six, the
8
+ shared half is stated once here and each colourway states its accent.
9
+
10
+ Every value is a `light-dark()` pair, resolved by the `color-scheme` set in
11
+ `contract.css`. That is the whole mechanism: no duplicated dark block, no
12
+ `prefers-color-scheme` query, and no exclusion list to fall behind.
13
+ ═══════════════════════════════════════════════════════════════════════════ */
14
+
15
+ :where(:root),
16
+ [data-nim-colorway='vermilion'],
17
+ [data-nim-colorway='oxblood'] {
18
+ /* ─── Surfaces ─────────────────────────────────────────────────────── */
19
+ --nim-canvas: light-dark(#f7f4ee, #14120e);
20
+ --nim-canvas-sunken: light-dark(#efeae1, #0e0c09);
21
+ --nim-surface: light-dark(#fffdf9, #1e1a15);
22
+ --nim-surface-raised: light-dark(#fffdf9, #262119);
23
+ /* Muted sits below the canvas in light and above the surface in dark.
24
+ Inverting the light relationship literally made it equal to `surface`,
25
+ which left a hovered row invisible. */
26
+ --nim-surface-muted: light-dark(#efeae1, #272219);
27
+ --nim-scrim: light-dark(rgba(23, 21, 15, 0.42), rgba(8, 7, 5, 0.66));
28
+
29
+ /* ─── Ink ────────────────────────────────────────────────────────────
30
+ Four levels, each clearing AA (4.5:1) on the canvas at 12px — the labels
31
+ in this palette are small, and small text is never "large text". */
32
+ --nim-ink: light-dark(#17150f, #f4f0e8);
33
+ --nim-ink-secondary: light-dark(#3a352b, rgba(244, 240, 232, 0.79));
34
+ --nim-ink-tertiary: light-dark(#5b5446, rgba(244, 240, 232, 0.61));
35
+ --nim-ink-inverse: light-dark(#f7f4ee, #14120e);
36
+
37
+ /* ─── Lines ──────────────────────────────────────────────────────────
38
+ Rules carry the structure under the ledger style, so they are
39
+ load-bearing rather than incidental: `line` divides, `strong` closes,
40
+ `soft` groups. The dark ramp is set by what stays visible on ink rather
41
+ than by symmetry with the light values — a hairline on paper is not a
42
+ hairline on ink. */
43
+ --nim-line: light-dark(rgba(23, 21, 15, 0.16), rgba(244, 240, 232, 0.24));
44
+ --nim-line-soft: light-dark(rgba(23, 21, 15, 0.09), rgba(244, 240, 232, 0.13));
45
+ --nim-line-strong: light-dark(rgba(23, 21, 15, 0.38), rgba(244, 240, 232, 0.46));
46
+
47
+ /* ─── Status ─────────────────────────────────────────────────────────
48
+ Status colours are not the brand's to choose: green means settled and red
49
+ means wrong in both of these palettes. */
50
+ --nim-success: light-dark(#1f6b4a, #5fd39a);
51
+ --nim-success-soft: light-dark(#e2efe8, rgba(95, 211, 154, 0.14));
52
+ --nim-warning: light-dark(#8a5a12, #e8b25f);
53
+ --nim-warning-soft: light-dark(#f4ebda, rgba(232, 178, 95, 0.14));
54
+ --nim-danger: light-dark(#a32a1c, #ff6f5e);
55
+ --nim-danger-hover: light-dark(#8c2417, #ff8776);
56
+ --nim-danger-soft: light-dark(#f6e5e2, rgba(255, 111, 94, 0.14));
57
+ --nim-info: light-dark(#2c5670, #8ab6d0);
58
+ --nim-info-soft: light-dark(#e3ecf1, rgba(138, 182, 208, 0.14));
59
+
60
+ /* ─── Shadow tint ────────────────────────────────────────────────────
61
+ The colour the active style's offsets are drawn in. In light this is the
62
+ rule colour, which is what makes the ledger offset read as a register
63
+ mark; in dark it must be true black, because a tint lighter than the
64
+ surface reads as a glow instead of depth. */
65
+ --nim-shadow-tint-sm: light-dark(rgba(23, 21, 15, 0.16), rgba(0, 0, 0, 0.5));
66
+ --nim-shadow-tint-md: light-dark(rgba(23, 21, 15, 0.09), rgba(0, 0, 0, 0.5));
67
+ --nim-shadow-tint-lg: light-dark(rgba(23, 21, 15, 0.09), rgba(0, 0, 0, 0.5));
68
+ }
@@ -0,0 +1,56 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ COLOURWAY · TEAL — clinical calm
3
+
4
+ The Fatemifar appointment platform's palette, carried over from the legacy
5
+ Angular client's Material ramp so the brand stays recognisable. Same neutral
6
+ hierarchy as `coral` — tinted canvas, white surfaces — cooled toward the
7
+ accent instead of warmed away from it.
8
+
9
+ The brand face (YekanBakh) is NOT set here: a colourway owns colour. An app
10
+ with its own typeface overrides `--nim-font-sans` on its provider.
11
+
12
+ Selected with `data-nim-colorway="teal"`.
13
+ ═══════════════════════════════════════════════════════════════════════════ */
14
+
15
+ [data-nim-colorway='teal'] {
16
+ --fa-teal-500: #00baba;
17
+ --fa-teal-600: #00a7a7;
18
+ --fa-teal-700: #008c8c;
19
+
20
+ --nim-canvas: light-dark(#f6faf9, #101b1a);
21
+ --nim-canvas-sunken: light-dark(#eff5f4, #0a1211);
22
+ --nim-surface: light-dark(#ffffff, #182725);
23
+ --nim-surface-raised: light-dark(#ffffff, #223634);
24
+ --nim-surface-muted: light-dark(#eef5f4, #1f302e);
25
+ --nim-scrim: light-dark(rgba(10, 32, 31, 0.52), rgba(5, 14, 13, 0.64));
26
+
27
+ --nim-ink: light-dark(#1d1d1f, #ecf5f4);
28
+ --nim-ink-secondary: light-dark(#56605f, #c3d2d0);
29
+ --nim-ink-tertiary: light-dark(#8b9695, #84918f);
30
+ --nim-ink-inverse: light-dark(#f7fcfb, #0a1211);
31
+ --nim-on-accent: light-dark(#ffffff, #04211f);
32
+
33
+ --nim-line: light-dark(rgba(16, 42, 41, 0.12), rgba(255, 255, 255, 0.16));
34
+ --nim-line-soft: light-dark(rgba(16, 42, 41, 0.07), rgba(255, 255, 255, 0.1));
35
+ --nim-line-strong: light-dark(rgba(16, 42, 41, 0.2), rgba(255, 255, 255, 0.3));
36
+
37
+ --nim-accent: light-dark(#00baba, #2ad4d4);
38
+ --nim-accent-hover: light-dark(#00a7a7, #4bdede);
39
+ --nim-accent-strong: light-dark(#008c8c, #00baba);
40
+ --nim-accent-soft: light-dark(rgba(0, 186, 186, 0.12), rgba(42, 212, 212, 0.15));
41
+ --nim-accent-line: light-dark(rgba(0, 186, 186, 0.26), rgba(42, 212, 212, 0.28));
42
+
43
+ --nim-success: light-dark(#2f8f5b, #6cc292);
44
+ --nim-success-soft: light-dark(rgba(47, 143, 91, 0.12), rgba(108, 194, 146, 0.14));
45
+ --nim-warning: light-dark(#b07a1f, #d9a94f);
46
+ --nim-warning-soft: light-dark(rgba(215, 154, 43, 0.14), rgba(217, 169, 79, 0.14));
47
+ --nim-danger: light-dark(#c0492f, #e0796a);
48
+ --nim-danger-hover: light-dark(#ab4029, #e88a7c);
49
+ --nim-danger-soft: light-dark(rgba(216, 92, 69, 0.12), rgba(224, 121, 106, 0.14));
50
+ --nim-info: light-dark(#33698c, #90b2c6);
51
+ --nim-info-soft: light-dark(rgba(51, 105, 140, 0.1), rgba(144, 178, 198, 0.12));
52
+
53
+ --nim-shadow-tint-sm: light-dark(rgba(10, 32, 31, 0.06), rgba(0, 0, 0, 0.44));
54
+ --nim-shadow-tint-md: light-dark(rgba(10, 32, 31, 0.08), rgba(0, 0, 0, 0.52));
55
+ --nim-shadow-tint-lg: light-dark(rgba(10, 32, 31, 0.1), rgba(0, 0, 0, 0.6));
56
+ }