@kbach/react-native 1.0.0-beta.36 → 1.0.0-beta.38

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/README.md CHANGED
@@ -1,510 +1,125 @@
1
1
  # @kbach/react-native
2
2
 
3
- Kbach's utility-class styling engine for React Native — the same class
4
- names as [`@kbach/react`](https://www.npmjs.com/package/@kbach/react),
5
- resolved to a `StyleSheet`-ready style object instead of injected CSS.
3
+ Utility-class styling for React Native — the same class names as
4
+ [`@kbach/react`](https://www.npmjs.com/package/@kbach/react), resolved to a
5
+ `StyleSheet`-ready style object instead of CSS.
6
6
 
7
- > Beta. Native is Android-only (no iOS bridge yet). Ships a prebuilt native
8
- > module — no Rust toolchain or manual native wiring required.
7
+ > **Beta.** Native is Android-only (no iOS bridge yet). Ships a prebuilt
8
+ > native module — no Rust toolchain or manual linking.
9
9
 
10
- ## Contents
11
-
12
- - [1. Install](#1-install)
13
- - [2. Set up your project](#2-set-up-your-project) — pick the ONE section matching how you run your app
14
- - [3. Configure your theme (`kbach.config.js`)](#3-configure-your-theme-kbachconfigjs)
15
- - [4. Dark mode](#4-dark-mode)
16
- - [5. Modifiers on native](#5-modifiers-on-native)
17
- - [6. Reading colors as values](#6-reading-colors-as-values)
18
- - [Reference](#reference): [arbitrary math](#arbitrary-math-calc-clamp-min-max) · [unknown-class warnings](#unknown-class-warnings-typo-detection) · [dynamic tokens](#dynamic-tokens) · [monorepo installs](#monorepo-installs-npmyarnpnpm-workspaces)
19
-
20
- ---
21
-
22
- ## 1. Install
10
+ ## Install
23
11
 
24
12
  ```sh
25
- npm install @kbach/react-native
13
+ npm install @kbach/react-native@beta
26
14
  ```
27
15
 
28
- ## 2. Set up your project
16
+ ## Setup
29
17
 
30
- Each subsection below is a **complete, standalone setup** for one way of
31
- running an RN/Expo app — read only the one that matches you, not all four.
32
-
33
- | How you run your app | Read |
18
+ | Target | What to do |
34
19
  | :-- | :-- |
35
- | `npx react-native ...` (React Native CLI) | [React Native CLI](#react-native-cli) |
36
- | `npx expo start`, scanning the QR code in the **Expo Go** app | [Expo Go](#expo-go) — nothing to configure |
37
- | `npx expo run:android`, a dev client, or EAS Build | [Expo (dev client / prebuild)](#expo-dev-client--prebuild) |
38
- | `npx expo start --web`, or any `react-native-web` target | [Expo Web](#expo-web) — nothing to configure |
39
-
40
- ### React Native CLI
41
-
42
- 1. Add the babel plugin:
43
-
44
- ```js
45
- // babel.config.js
46
- module.exports = {
47
- presets: ['module:@react-native/babel-preset'],
48
- plugins: ['@kbach/react-native/babel-plugin'],
49
- };
50
- ```
51
-
52
- 2. Rebuild the Android app (`npx react-native run-android`) so Gradle picks
53
- up the native module.
54
- 3. Continue to [Configure your theme](#3-configure-your-theme-kbachconfigjs) (optional, but almost always what you want next).
55
-
56
- ### Expo Go
57
-
58
- **Nothing to configure at all.** Skip straight to
59
- [Configure your theme](#3-configure-your-theme-kbachconfigjs) — or just
60
- start writing `className` right away using the defaults.
61
-
62
- Expo Go can't load third-party native code, so this falls back to a pure-JS
63
- engine automatically. Covers layout, spacing, border/radius, plain colors,
64
- font-weight, text-transform/decoration, line-height, letter-spacing,
65
- shadows, and transforms (`translate-x`/`translate-y`, `scale`/`scale-x`/
66
- `scale-y`, `rotate`/`rotate-x`/`rotate-y`/`rotate-z`, `skew-x`/`skew-y`,
67
- `transform-none`, `backface-visible`/`backface-hidden`). Doesn't cover grid,
68
- filters, gradients, DOM-only typography, or the rest of the transform family
69
- (`translate-z`/`scale-z`, `perspective`/`perspective-origin`/`origin`,
70
- `transform-gpu`/`transform-cpu` — no RN equivalent for any of these) — same
71
- scope native itself excludes.
72
-
73
- ### Expo (dev client / prebuild)
74
-
75
- Gets you the full native engine instead of the JS fallback.
76
-
77
- 1. Add the babel plugin — a fresh Expo project's generated `babel.config.js`
78
- uses the function form below (`api.cache(true)`), not a plain exported
79
- object; add the plugin to the array **inside** the returned config,
80
- keep everything else as-is:
81
-
82
- ```js
83
- // babel.config.js
84
- module.exports = function (api) {
85
- api.cache(true);
86
- return {
87
- presets: ['babel-preset-expo'],
88
- plugins: ['@kbach/react-native/babel-plugin'], // <- add this line
89
- };
90
- };
91
- ```
92
-
93
- 2. Confirm the New Architecture is enabled — `"newArchEnabled": true` in
94
- `app.json` (on by default in current Expo SDKs).
95
- 3. Prebuild and run:
96
-
97
- ```sh
98
- npx expo prebuild
99
- npx expo run:android
100
- ```
101
-
102
- EAS Build works the same way, no extra config.
103
- 4. Continue to [Configure your theme](#3-configure-your-theme-kbachconfigjs) (optional, but almost always what you want next).
104
-
105
- ### Expo Web
106
-
107
- **Nothing to configure at all.** Resolves through the same real-CSS engine
108
- `@kbach/react` uses on the DOM, with the FULL modifier set (`group-hover:`,
109
- `peer-hover:`, container queries, `has-[...]:`, etc.) — everything real
110
- Tailwind supports. Native supports a smaller, explicitly-scoped subset; see
111
- [Modifiers on native](#5-modifiers-on-native) for exactly what that covers.
112
-
113
- **Static rendering (SSR) caveat:** `dark:`/responsive classes on content
114
- that's part of the initial pre-rendered HTML resolve against the *server*,
115
- not the visiting browser — so a user in dark mode can briefly see the light
116
- version on first paint. Defer preference-dependent classes until mounted:
20
+ | **Expo Go** | Nothing. A pure-JS fallback engine loads automatically. |
21
+ | **Expo Web** / `react-native-web` | Nothing. Resolves through the same CSS engine `@kbach/react` uses. |
22
+ | **Expo dev client / prebuild / EAS** | Add the babel plugin (below), then `npx expo prebuild && npx expo run:android`. |
23
+ | **React Native CLI** | Add the babel plugin (below), then rebuild the app. |
117
24
 
118
- ```tsx
119
- const [mounted, setMounted] = useState(false);
120
- useEffect(() => setMounted(true), []);
121
-
122
- <View className={mounted ? 'bg-gray-1 dark:bg-gray-12' : 'bg-gray-1'} />;
123
- ```
124
-
125
- ## 3. Configure your theme (`kbach.config.js`)
126
-
127
- This step is **optional** — every class already works against a built-in
128
- default palette/spacing/breakpoint scale with no config file at all. Add a
129
- `kbach.config.js` when you want your own color names, custom spacing, or
130
- extra breakpoints.
131
-
132
- 1. Create a **`kbach.config.js` file at your project root** (same folder as
133
- `package.json` — the one you run `expo start`/`react-native start` from):
134
-
135
- ```js
136
- // kbach.config.js
137
- module.exports = {
138
- extend: {
139
- colors: {
140
- brand: '#ff6b35',
141
- },
142
- },
143
- };
144
- ```
145
-
146
- 2. That's it — **no import, no `applyKbachConfig()` call needed.** If you
147
- completed step 2 above (the babel plugin is installed), it detects
148
- `kbach.config.js` automatically and applies it before your app's first
149
- render. You only need to call `applyKbachConfig()`/`resolveKbachConfig()`
150
- yourself if you're changing the theme again at *runtime* (e.g. loading a
151
- different config after login) — see the exports below.
152
- 3. Use it: `<View className="bg-brand" />`.
153
-
154
- > **Common mistake:** `kbach.config.js` is a plain **JavaScript** file, not
155
- > JSON — an object key containing a hyphen (`surface-dim`) must be quoted
156
- > as a string (`'surface-dim': '#121318'`), or it's invalid JS syntax and
157
- > the whole config file fails to load. Any key that's a valid identifier
158
- > (letters/digits/underscore, e.g. `brand`, `surface2`) can stay unquoted.
159
-
160
- ### `extend.colors`
161
-
162
- Every value is either a literal color, or a **reference** to another color
163
- name (optionally with an opacity suffix):
25
+ The babel plugin, added to your existing `babel.config.js`:
164
26
 
165
27
  ```js
166
- colors: {
167
- brand: '#ff6b35', // literal hex
168
- accent: 'orange-5', // references a BUILT-IN palette color
169
- accentSoft: 'orange-5/50', // ...at 50% opacity
170
- brandSoft: 'brand/30', // references a color defined EARLIER in this object, at 30% opacity
171
- danger: 'red', // not a known color name -> used as a literal CSS value
172
- }
28
+ // Expo — keep the function form your project already has:
29
+ module.exports = function (api) {
30
+ api.cache(true);
31
+ return {
32
+ presets: ['babel-preset-expo'],
33
+ plugins: ['@kbach/react-native/babel-plugin'],
34
+ };
35
+ };
173
36
  ```
174
37
 
175
- **Mode-aware colors** (a different value per light/dark) — two equivalent
176
- ways to write one:
177
-
178
38
  ```js
179
- // Per-color pair — good for one or two mode-aware colors
180
- colors: {
181
- surface: { light: 'gray-2', dark: 'gray-11' },
182
- }
183
-
184
- // Grouped "dark" block — good when MOST of your colors are mode-aware
185
- colors: {
186
- surface: 'gray-2',
187
- card: 'white',
188
- dark: {
189
- surface: 'gray-11',
190
- card: 'gray-9',
191
- },
192
- }
39
+ // React Native CLI:
40
+ module.exports = {
41
+ presets: ['module:@react-native/babel-preset'],
42
+ plugins: ['@kbach/react-native/babel-plugin'],
43
+ };
193
44
  ```
194
45
 
195
- Both produce the same result: `bg-surface` (no `dark:` prefix needed)
196
- automatically resolves to the right shade for the current mode. `dark` is a
197
- reserved key here, not a color name — a color never mentioned under `dark`
198
- stays a plain, mode-independent color. A name written under `dark` with no
199
- matching top-level entry is skipped (mode-aware colors need both sides).
46
+ Then write classes:
200
47
 
201
- ### `extend.spacing` / `extend.screens` / `extend.fontFamily` / `extend.container`
48
+ ```tsx
49
+ import { View, Text } from 'react-native';
202
50
 
203
- ```js
204
- module.exports = {
205
- extend: {
206
- spacing: { 128: 512 }, // p-128 -> 512
207
- screens: { '3xl': 1920 }, // 3xl:flex-row
208
- fontFamily: { display: '"Cal Sans", sans-serif' }, // font-display
209
- container: { center: true, padding: '2rem' },
210
- },
211
- };
51
+ <View className="flex-1 items-center justify-center bg-white dark:bg-black">
52
+ <Text className="text-lg font-bold">Hello Kbach</Text>
53
+ </View>;
212
54
  ```
213
55
 
214
- ### `theme` (replace instead of extend)
56
+ ## Theme
215
57
 
216
- `theme.colors`/`theme.spacing`/`theme.screens`/`theme.fontFamily` **replace**
217
- the built-in defaults entirely instead of adding to them — use this only if
218
- you want to drop the default palette/scale altogether:
58
+ Optional. Create `kbach.config.js` at your project root — the babel plugin
59
+ picks it up automatically, no import or `applyKbachConfig()` call needed:
219
60
 
220
61
  ```js
62
+ // kbach.config.js
221
63
  module.exports = {
222
- theme: {
223
- colors: { brand: '#ff6b35' }, // ONLY color available now — the default 22-family palette is gone
64
+ extend: {
65
+ colors: {
66
+ brand: '#ff6b35',
67
+ surface: 'gray-2',
68
+ dark: { surface: 'gray-11' }, // makes `bg-surface` mode-aware
69
+ },
224
70
  },
225
71
  };
226
72
  ```
227
73
 
228
- ### `darkMode`
74
+ It's plain JavaScript — **quote any key containing a hyphen**
75
+ (`'surface-dim': '#121318'`).
229
76
 
230
- ```js
231
- module.exports = {
232
- darkMode: 'attribute', // 'attribute' (default) | 'class' | 'media'
233
- };
234
- ```
235
-
236
- Controls how Expo Web reflects dark mode in the DOM; has no effect on real
237
- native, which always resolves `dark:` from a live parameter (see
238
- [Dark mode](#4-dark-mode) below).
77
+ Full config reference (colors, spacing, breakpoints, fonts, dark mode) is
78
+ in [AGENTS.md](https://github.com/semsakadanupol/kbach/blob/main/AGENTS.md).
239
79
 
240
- ## 4. Dark mode
80
+ ## Dark mode
241
81
 
242
- **Zero setup, on every platform this package supports** — real native
243
- *and* Expo Web. Unlike [`@kbach/react`](https://www.npmjs.com/package/@kbach/react),
244
- there's no build-time CSS step here to keep in sync with the runtime:
245
- `dark:` is resolved fresh from a live parameter on native (no selectors at
246
- all to generate ahead of time), and Expo Web's default strategy is already
247
- `'attribute'` — a manual toggle just works, no config file, no
248
- `applyKbachConfig()` call:
82
+ Zero setup on every target. `dark:` classes react to the OS setting;
83
+ `useTheme()` gives you a manual toggle:
249
84
 
250
85
  ```tsx
251
86
  import { useTheme } from '@kbach/react-native';
252
87
 
253
- function ThemeToggle() {
254
- const { mode, isDark, setMode, toggle } = useTheme();
255
- return <Pressable onPress={toggle}><Text>{mode}</Text></Pressable>;
256
- }
88
+ const { mode, isDark, setMode, toggle } = useTheme();
257
89
  ```
258
90
 
259
- Works anywhere, no provider needed. `<ThemeProvider defaultMode="dark">` is
260
- optional, for seeding a startup default. Outside React, use
261
- `getGlobalDarkMode()`/`toggleGlobalDarkMode()` instead.
91
+ Works anywhere, no provider. `<ThemeProvider>` is optional — for seeding a
92
+ startup default and opt-in persistence (`<ThemeProvider persist={AsyncStorage}>`).
262
93
 
263
- Follows the OS setting live until overridden, and isn't persisted across
264
- app launches by default (no forced storage dependency). To persist, pass
265
- any async key-value store exposing `getItem`/`setItem` as `<ThemeProvider
266
- persist>` — `@react-native-async-storage/async-storage`'s default export
267
- already matches this shape exactly:
94
+ ## Colors as values
268
95
 
269
- ```tsx
270
- import AsyncStorage from '@react-native-async-storage/async-storage';
271
- import { ThemeProvider } from '@kbach/react-native';
272
-
273
- <ThemeProvider persist={AsyncStorage}>
274
- <App />
275
- </ThemeProvider>;
276
- ```
277
-
278
- The persisted mode (if any) is read once on mount and wins over
279
- `defaultMode`; every later explicit choice is written back automatically.
280
- `false` by default — this package still never imports a storage library
281
- itself, so nothing changes for apps that don't opt in. Prefer wiring your
282
- own storage manually instead? `setGlobalThemeMode()` before your first
283
- render still works exactly as before.
284
-
285
- ## 5. Modifiers on native
286
-
287
- Native has no CSS selector/cascade engine, so only a fixed, explicitly
288
- supported set of modifiers actually applies — everything else (`group-*:`,
289
- `peer-*:`, `has-[...]:`, `aria-*:`, `data-*:`, container queries, ...)
290
- parses without error but never takes effect. Supported today:
291
-
292
- - **`dark:`** — reacts to the OS/app color scheme, see [Dark mode](#4-dark-mode).
293
- - **`sm:`/`md:`/`lg:`/`xl:`/`2xl:`** — reacts to `useWindowDimensions()`
294
- crossing the matching `theme.screens` breakpoint (rotation/resize/fold).
295
- - **`active:`** — `Pressable`'s own press state, via its style-callback API.
296
- - **`hover:`** — tracked via `onHoverIn`/`onHoverOut`, which RN only ever
297
- fires on a platform with real pointer input (iOS 13+/Android/macOS/
298
- Windows via a mouse/trackpad/Apple Pencil hover) — inert (never fires) on
299
- a touch-only device, the same way real Tailwind's `hover:` already
300
- degrades on a touch-only *browser*. Composing it onto any host type is
301
- harmless even where it never fires.
302
- - **`focus:`** — tracked via `onFocus`/`onBlur` (keyboard/external-keyboard/
303
- TV-remote/tab navigation, and `TextInput` focus).
304
- - **`disabled:`** — reads the element's own `disabled` prop directly; no
305
- event tracking needed, since a prop change already re-renders normally.
306
- - Any combination of the above chained on one class (`sm:dark:bg-blue-8`,
307
- `hover:focus:opacity-100`) requires every named condition to hold at once
308
- — chain order never matters (`dark:sm:` and `sm:dark:` are equivalent).
309
-
310
- `group-hover:`/`peer-hover:` and friends are NOT implemented on native —
311
- doing so needs a React Context bridge (propagating an ancestor's hover/
312
- press/focus state down to descendants, since there's no `.group`/`.peer`
313
- selector relationship to lean on) that doesn't exist today. If you need
314
- group/peer-style coordination on native right now, lift the shared state
315
- into a parent component yourself and pass it down as an explicit prop.
316
-
317
- ## 6. Reading colors as values
318
-
319
- For anywhere you need a real color, not a `className` — a chart prop, a
320
- native shadow color:
96
+ For a real color string rather than a class — a chart prop, a shadow color:
321
97
 
322
98
  ```tsx
323
99
  import { useColors } from '@kbach/react-native';
324
100
 
325
101
  const colors = useColors();
326
- colors.blue[6]; // '#2563eb' — static, same value in both modes
327
- colors.blue['6/50']; // same, at 50% opacity
328
- ```
329
-
330
- A shade-family lookup (`colors.blue[n]`) auto-mirrors to shade `13 - n` in
331
- dark mode — this palette's own convention is 1 = lightest, 12 = darkest, so
332
- that's the same visual weight relative to its own background, no manual
333
- `colors.blue[isDark ? 7 : 6]` needed.
334
-
335
- **TypeScript error on a custom color?** `colors.brand` (any custom color
336
- from `kbach.config.js`) types as `string | ColorScale`, not plain `string`
337
- — `colors` serves both a flat color AND a shade family (`colors.blue`)
338
- through the same object, and TypeScript can't statically tell which one a
339
- given name is (your config is plain runtime JS, not a type declaration).
340
- If that union causes an error somewhere expecting a real `string` (a
341
- native style prop, a chart library), use `colors.get('brand')` instead —
342
- same value, typed as plain `string`:
343
-
344
- ```tsx
345
- colors.brand; // string | ColorScale — errors if assigned to a `string`-typed prop
346
- colors.get('brand'); // string — same value, always typed correctly
347
- ```
348
-
349
- ---
350
-
351
- ## Reference
352
-
353
- Deeper/less-common material — you likely won't need this on your first pass.
354
-
355
- ### Arbitrary math: `calc()`, `clamp()`, `min()`, `max()`
356
-
357
- ```tsx
358
- <View className="p-[calc(16px+8px)]" /> // -> padding: 24
359
- <View className="w-[clamp(1rem,2rem,3rem)]" /> // -> width: 32
360
- ```
361
-
362
- Resolves at **build/resolve time**, not runtime — every operand must be a
363
- constant `px`/`rem` length (or a bare number). This works because native has
364
- no CSS engine to evaluate a real `calc()` string at paint time, so the only
365
- way to support it at all is to compute the answer ourselves.
366
-
367
- A **viewport-relative** expression (`calc(10vw+8px)`) can't be reduced this
368
- way — no such unit exists on native at all. It already works fine on web
369
- (real CSS); on native the declaration is dropped and a `console.warn`
370
- explains why (dev builds only).
371
-
372
- **Percentage-relative width/height** (`calc(100%-3rem)`, `calc(50%+16px)`,
373
- `min(50%,20rem)`, `max(1rem,5%)`, `clamp(16rem,50%,32rem)`) IS supported,
374
- differently: since native has no way to know "100% of what" ahead of time,
375
- this measures the element's own real layout instead —
376
-
377
- ```tsx
378
- <View className="w-[calc(100%_-_3rem)]" />
379
- <View className="w-[min(50%,20rem)]" /> // half the parent, capped at a fixed size
380
- <View className="w-[clamp(16rem,50%,32rem)]" />
102
+ colors.blue[6]; // '#2563eb'
103
+ colors.blue['6/50']; // same at 50% opacity
104
+ colors.get('brand'); // a custom color, typed as plain `string`
381
105
  ```
382
106
 
383
- Renders once at a plain `100%` (a real RN percentage, resolved correctly by
384
- RN's own layout engine against the parent) purely to trigger a layout
385
- event, then immediately corrects to the actual computed number once
386
- measured — a genuine one-frame measure-then-snap, not instant math like the
387
- constant-only case above, and it keeps re-measuring on every layout (screen
388
- rotation, parent resize, ...) rather than freezing after the first value.
389
- Scoped narrowly, same "reduce fully or bail" philosophy as the constant-only
390
- case: `calc()` takes exactly one percentage term plus one constant term;
391
- `min()`/`max()` take two or more comma-separated arguments, each EITHER a
392
- plain percentage or a constant px/rem expression (not both mixed in one
393
- argument — `min(50%-1rem,20rem)` is out of scope); `clamp()` takes exactly
394
- three. `width`/`height` only in every case — other properties, or anything
395
- that doesn't reduce fully, falls through to the same drop-and-warn behavior
396
- as before.
397
-
398
- **Arbitrary values can't contain literal spaces** — this is a pre-existing
399
- rule (real Tailwind has the same one), not specific to `calc()`: a space
400
- inside `[...]` is indistinguishable from the whitespace that separates
401
- class tokens in the first place, so `w-[calc(100% - 3rem)]` silently splits
402
- into three broken tokens before `calc()` is ever even recognized. Write
403
- `w-[calc(100%_-_3rem)]` (underscores) instead — this is exactly what the
404
- unknown-class warning below is for: it'll tell you when this happens.
405
-
406
- ### Unknown-class warnings (typo detection)
407
-
408
- ```tsx
409
- <View className="flexx-center" /> // real Kbach classes never have to guess — a typo warns
410
- ```
411
-
412
- ```
413
- Kbach: "flexx-center" doesn't match any known Kbach utility — typo? (skipped) (from className "flexx-center")
414
- ```
107
+ ## What differs on native
415
108
 
416
- On native/dev-client builds, a class that doesn't match *any* Kbach
417
- utility on *any* platform gets a `console.warn` (dev builds only, printed
418
- once per unique message) instead of silently doing nothing — the same
419
- signal `@kbach/react`'s Vite plugin already gives you at build time on
420
- web, just at resolve time here instead. A real utility this engine simply
421
- doesn't support on native yet (`grid-cols-3`, `scale-150`, `blur`, ...)
422
- never warns — that's an intentional, documented platform gap, not a typo,
423
- so it stays silent the same way it always has.
109
+ React Native has no CSS cascade, so a fixed subset of modifiers is
110
+ supported — `dark:`, `sm:`–`2xl:`, `active:`, `hover:`, `focus:`,
111
+ `disabled:`, and `data-[…]:`/`aria-[…]:`. `group-*`/`peer-*`/`has-[…]` and
112
+ container queries parse without error but do not apply. When two classes
113
+ set the same property the more-specific one wins regardless of source
114
+ order (`dark:bg-black bg-white` → black in dark mode).
424
115
 
425
- Not available in Expo Go's pure-JS fallback engine today — that engine has
426
- no web-shaped resolver to check "is this a real Kbach utility at all"
427
- against, only the (smaller) native-supported subset, so it can't
428
- distinguish a genuine typo from a real-but-native-unsupported class
429
- without a much larger port. A real dev-client/CLI build always gets this
430
- check.
431
-
432
- ### Dynamic tokens
433
-
434
- The native counterpart to a real CSS custom property — `var(--x)` already
435
- works in a `className` on Expo Web with zero help from this package (real
436
- CSS, resolved by the browser). This gives native the same className syntax:
437
-
438
- ```tsx
439
- import { setDynamicToken, useDynamicToken } from '@kbach/react-native';
440
-
441
- setDynamicToken('sidebar-width', '240px'); // updates every subscribed element, on both platforms
442
- <View className="w-[var(--sidebar-width)]" />
443
-
444
- // Reading the raw value in JS, reactively:
445
- function Sidebar() {
446
- const width = useDynamicToken('sidebar-width'); // '240px' | undefined
447
- }
448
- ```
449
-
450
- On web, `setDynamicToken` writes the real CSS custom property to the DOM —
451
- every element using it repaints instantly via the browser's own CSS engine,
452
- no React re-render involved. On native, the className is substituted with
453
- the token's current value before every resolve, and an element referencing
454
- a token re-renders/repaints on its own when that token changes (same
455
- subscription mechanism `dark:` and `sm:`/`md:`/... already use). An
456
- unregistered token name falls through to the same native invalid-value
457
- warning above.
458
-
459
- ### Monorepo installs (npm/yarn/pnpm workspaces)
460
-
461
- If your app lives in a monorepo where `@kbach/react-native` is a workspace
462
- package (a `file:`/`link:` dependency, or hoisted rather than nested), watch
463
- for **two physical copies of `react-native`/`react`** ending up in your
464
- dependency tree — one this package would otherwise resolve up to from its
465
- own directory, and a different one your app's own files resolve to. Metro
466
- walks `node_modules` hierarchically per-file, so it can silently hand two
467
- different files two different copies of the *same* module, each with its
468
- own separate module state.
469
-
470
- This isn't hypothetical: it's the root cause of a real dark-mode bug this
471
- package shipped and fixed — `useColorScheme()`/`Appearance` in one copy
472
- never saw changes made through the other, so an explicit theme toggle
473
- appeared to do nothing. The fix is a Metro config override that forces a
474
- single instance, **required in your app**, not something this package can
475
- do on your behalf from inside `node_modules`:
476
-
477
- ```js
478
- // metro.config.js
479
- const path = require('path');
480
- // React Native CLI: const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
481
- // Expo: const { getDefaultConfig } = require('expo/metro-config');
482
-
483
- const projectRoot = __dirname;
484
- const workspaceRoot = path.resolve(projectRoot, '../..'); // -> your monorepo root
485
-
486
- const config = getDefaultConfig(projectRoot);
487
- config.watchFolders = [workspaceRoot];
488
-
489
- const forcedSingleInstance = {
490
- 'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
491
- react: path.resolve(projectRoot, 'node_modules/react'),
492
- };
493
- const defaultResolveRequest = config.resolver.resolveRequest;
494
- config.resolver.resolveRequest = (context, moduleName, platform) => {
495
- if (Object.prototype.hasOwnProperty.call(forcedSingleInstance, moduleName)) {
496
- return { type: 'sourceFile', filePath: require.resolve(forcedSingleInstance[moduleName]) };
497
- }
498
- return defaultResolveRequest
499
- ? defaultResolveRequest(context, moduleName, platform)
500
- : context.resolveRequest(context, moduleName, platform);
501
- };
502
-
503
- module.exports = config; // (React Native CLI: mergeConfig(getDefaultConfig(projectRoot), config))
504
- ```
116
+ Expo Go covers a documented subset of utilities (no grid, filters,
117
+ gradients, or DOM-only typography). Expo Web and a real dev-client build
118
+ cover the full set.
505
119
 
506
- A single, non-monorepo `npm install` never hits this — only relevant if your
507
- app and `@kbach/react-native` share a workspace root.
120
+ See [AGENTS.md](https://github.com/semsakadanupol/kbach/blob/main/AGENTS.md)
121
+ for the complete reference — modifier list, `calc()`/`min()`/`max()`
122
+ support, dynamic tokens, monorepo setup, and how the engine works.
508
123
 
509
124
  ## License
510
125
 
package/dist/index.js CHANGED
@@ -1605,7 +1605,9 @@ var Evaluator = class {
1605
1605
 
1606
1606
  // src/jsEngine/resolveStyle.ts
1607
1607
  var RESPONSIVE_BREAKPOINTS = /* @__PURE__ */ new Set(["sm", "md", "lg", "xl", "2xl"]);
1608
+ var STATE_HOLD_MARKER = "_kbon";
1608
1609
  function nativeModifierState(modifier, theme, colorScheme, pressed, width) {
1610
+ if (modifier === STATE_HOLD_MARKER) return true;
1609
1611
  if (modifier === "dark") return colorScheme === "dark";
1610
1612
  if (modifier === "active") return pressed;
1611
1613
  if (RESPONSIVE_BREAKPOINTS.has(modifier)) {
@@ -1744,6 +1746,8 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
1744
1746
  const warnings = [];
1745
1747
  let shadowOffset = null;
1746
1748
  const transformOps = /* @__PURE__ */ new Map();
1749
+ const keySpecificity = /* @__PURE__ */ new Map();
1750
+ const transformSpecificity = /* @__PURE__ */ new Map();
1747
1751
  for (const rawToken of classString.split(/\s+/).filter(Boolean)) {
1748
1752
  const token = substituteModeAwareColorToken(rawToken, theme, colorScheme);
1749
1753
  const parsed = parseClass(token);
@@ -1753,31 +1757,44 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
1753
1757
  if (!allModifiersHold) continue;
1754
1758
  const decls = resolveUtilityNative(parsed, theme);
1755
1759
  if (decls === null) continue;
1760
+ const spec = parsed.modifiers.length;
1756
1761
  for (const d of decls) {
1757
1762
  if (d.property.startsWith("__")) {
1758
1763
  continue;
1759
1764
  }
1760
1765
  if (d.property === "shadow-offset-x" || d.property === "shadow-offset-y") {
1766
+ if (spec < (keySpecificity.get("shadowOffset") ?? 0)) continue;
1761
1767
  const n = Number(d.value);
1762
1768
  if (!Number.isFinite(n)) continue;
1763
1769
  if (shadowOffset === null) shadowOffset = {};
1764
1770
  shadowOffset[d.property === "shadow-offset-x" ? "width" : "height"] = n;
1771
+ keySpecificity.set("shadowOffset", spec);
1765
1772
  continue;
1766
1773
  }
1767
1774
  if (d.property === "transform-op-none") {
1768
1775
  transformOps.clear();
1776
+ transformSpecificity.clear();
1769
1777
  continue;
1770
1778
  }
1771
1779
  const opKey = TRANSFORM_OP_KEYS.get(d.property);
1772
1780
  if (opKey !== void 0) {
1781
+ if (spec < (transformSpecificity.get(opKey) ?? 0)) continue;
1773
1782
  const { value: value2, warning: warning2 } = rnStyleValue(d.property, d.value);
1774
1783
  if (warning2 !== null) warnings.push(warning2);
1775
- if (value2 !== null) transformOps.set(opKey, value2);
1784
+ if (value2 !== null) {
1785
+ transformOps.set(opKey, value2);
1786
+ transformSpecificity.set(opKey, spec);
1787
+ }
1776
1788
  continue;
1777
1789
  }
1790
+ const key = kebabToCamel(d.property);
1791
+ if (spec < (keySpecificity.get(key) ?? 0)) continue;
1778
1792
  const { value, warning } = rnStyleValue(d.property, d.value);
1779
1793
  if (warning !== null) warnings.push(warning);
1780
- if (value !== null) style[kebabToCamel(d.property)] = value;
1794
+ if (value !== null) {
1795
+ style[key] = value;
1796
+ keySpecificity.set(key, spec);
1797
+ }
1781
1798
  }
1782
1799
  }
1783
1800
  if (shadowOffset !== null) {
@@ -725,9 +725,10 @@ function propBasedModifierState(name, states, hostRest) {
725
725
  if (ariaInner !== null) return evalAttrCondition(hostRest, "aria-", ariaInner);
726
726
  return null;
727
727
  }
728
- function stripModifiers(parts, namesToStrip) {
728
+ var STATE_HOLD_MARKER = "_kbon";
729
+ function markSatisfiedStateModifiers(parts, namesToReplace) {
729
730
  const utility = parts[parts.length - 1] ?? "";
730
- const modifiers = parts.slice(0, -1).filter((m) => !namesToStrip.has(m));
731
+ const modifiers = parts.slice(0, -1).map((m) => namesToReplace.has(m) ? STATE_HOLD_MARKER : m);
731
732
  return [...modifiers, utility].join(":");
732
733
  }
733
734
  function substituteStateModifiers(classStrRaw, states, hostRest) {
@@ -745,7 +746,7 @@ function substituteStateModifiers(classStrRaw, states, hostRest) {
745
746
  }
746
747
  if (required.every(([, v]) => v === true)) {
747
748
  const requiredNames = new Set(required.map(([m]) => m));
748
- kept.push(stripModifiers(parts, requiredNames));
749
+ kept.push(markSatisfiedStateModifiers(parts, requiredNames));
749
750
  }
750
751
  }
751
752
  return kept.join(" ");
@@ -1988,7 +1989,9 @@ function resolveUtilityNative(parsed, theme) {
1988
1989
 
1989
1990
  // src/jsEngine/resolveStyle.ts
1990
1991
  var RESPONSIVE_BREAKPOINTS = /* @__PURE__ */ new Set(["sm", "md", "lg", "xl", "2xl"]);
1992
+ var STATE_HOLD_MARKER2 = "_kbon";
1991
1993
  function nativeModifierState(modifier, theme, colorScheme, pressed, width) {
1994
+ if (modifier === STATE_HOLD_MARKER2) return true;
1992
1995
  if (modifier === "dark") return colorScheme === "dark";
1993
1996
  if (modifier === "active") return pressed;
1994
1997
  if (RESPONSIVE_BREAKPOINTS.has(modifier)) {
@@ -2127,6 +2130,8 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
2127
2130
  const warnings = [];
2128
2131
  let shadowOffset = null;
2129
2132
  const transformOps = /* @__PURE__ */ new Map();
2133
+ const keySpecificity = /* @__PURE__ */ new Map();
2134
+ const transformSpecificity = /* @__PURE__ */ new Map();
2130
2135
  for (const rawToken of classString.split(/\s+/).filter(Boolean)) {
2131
2136
  const token = substituteModeAwareColorToken(rawToken, theme, colorScheme);
2132
2137
  const parsed = parseClass(token);
@@ -2136,31 +2141,44 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
2136
2141
  if (!allModifiersHold) continue;
2137
2142
  const decls = resolveUtilityNative(parsed, theme);
2138
2143
  if (decls === null) continue;
2144
+ const spec = parsed.modifiers.length;
2139
2145
  for (const d of decls) {
2140
2146
  if (d.property.startsWith("__")) {
2141
2147
  continue;
2142
2148
  }
2143
2149
  if (d.property === "shadow-offset-x" || d.property === "shadow-offset-y") {
2150
+ if (spec < (keySpecificity.get("shadowOffset") ?? 0)) continue;
2144
2151
  const n = Number(d.value);
2145
2152
  if (!Number.isFinite(n)) continue;
2146
2153
  if (shadowOffset === null) shadowOffset = {};
2147
2154
  shadowOffset[d.property === "shadow-offset-x" ? "width" : "height"] = n;
2155
+ keySpecificity.set("shadowOffset", spec);
2148
2156
  continue;
2149
2157
  }
2150
2158
  if (d.property === "transform-op-none") {
2151
2159
  transformOps.clear();
2160
+ transformSpecificity.clear();
2152
2161
  continue;
2153
2162
  }
2154
2163
  const opKey = TRANSFORM_OP_KEYS.get(d.property);
2155
2164
  if (opKey !== void 0) {
2165
+ if (spec < (transformSpecificity.get(opKey) ?? 0)) continue;
2156
2166
  const { value: value2, warning: warning2 } = rnStyleValue(d.property, d.value);
2157
2167
  if (warning2 !== null) warnings.push(warning2);
2158
- if (value2 !== null) transformOps.set(opKey, value2);
2168
+ if (value2 !== null) {
2169
+ transformOps.set(opKey, value2);
2170
+ transformSpecificity.set(opKey, spec);
2171
+ }
2159
2172
  continue;
2160
2173
  }
2174
+ const key = kebabToCamel(d.property);
2175
+ if (spec < (keySpecificity.get(key) ?? 0)) continue;
2161
2176
  const { value, warning } = rnStyleValue(d.property, d.value);
2162
2177
  if (warning !== null) warnings.push(warning);
2163
- if (value !== null) style[kebabToCamel(d.property)] = value;
2178
+ if (value !== null) {
2179
+ style[key] = value;
2180
+ keySpecificity.set(key, spec);
2181
+ }
2164
2182
  }
2165
2183
  }
2166
2184
  if (shadowOffset !== null) {
@@ -726,9 +726,10 @@ function propBasedModifierState(name, states, hostRest) {
726
726
  if (ariaInner !== null) return evalAttrCondition(hostRest, "aria-", ariaInner);
727
727
  return null;
728
728
  }
729
- function stripModifiers(parts, namesToStrip) {
729
+ var STATE_HOLD_MARKER = "_kbon";
730
+ function markSatisfiedStateModifiers(parts, namesToReplace) {
730
731
  const utility = parts[parts.length - 1] ?? "";
731
- const modifiers = parts.slice(0, -1).filter((m) => !namesToStrip.has(m));
732
+ const modifiers = parts.slice(0, -1).map((m) => namesToReplace.has(m) ? STATE_HOLD_MARKER : m);
732
733
  return [...modifiers, utility].join(":");
733
734
  }
734
735
  function substituteStateModifiers(classStrRaw, states, hostRest) {
@@ -746,7 +747,7 @@ function substituteStateModifiers(classStrRaw, states, hostRest) {
746
747
  }
747
748
  if (required.every(([, v]) => v === true)) {
748
749
  const requiredNames = new Set(required.map(([m]) => m));
749
- kept.push(stripModifiers(parts, requiredNames));
750
+ kept.push(markSatisfiedStateModifiers(parts, requiredNames));
750
751
  }
751
752
  }
752
753
  return kept.join(" ");
@@ -1979,7 +1980,9 @@ function resolveUtilityNative(parsed, theme) {
1979
1980
 
1980
1981
  // src/jsEngine/resolveStyle.ts
1981
1982
  var RESPONSIVE_BREAKPOINTS = /* @__PURE__ */ new Set(["sm", "md", "lg", "xl", "2xl"]);
1983
+ var STATE_HOLD_MARKER2 = "_kbon";
1982
1984
  function nativeModifierState(modifier, theme, colorScheme, pressed, width) {
1985
+ if (modifier === STATE_HOLD_MARKER2) return true;
1983
1986
  if (modifier === "dark") return colorScheme === "dark";
1984
1987
  if (modifier === "active") return pressed;
1985
1988
  if (RESPONSIVE_BREAKPOINTS.has(modifier)) {
@@ -2118,6 +2121,8 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
2118
2121
  const warnings = [];
2119
2122
  let shadowOffset = null;
2120
2123
  const transformOps = /* @__PURE__ */ new Map();
2124
+ const keySpecificity = /* @__PURE__ */ new Map();
2125
+ const transformSpecificity = /* @__PURE__ */ new Map();
2121
2126
  for (const rawToken of classString.split(/\s+/).filter(Boolean)) {
2122
2127
  const token = substituteModeAwareColorToken(rawToken, theme, colorScheme);
2123
2128
  const parsed = parseClass(token);
@@ -2127,31 +2132,44 @@ function resolveStyleJsWithWarnings(classString, theme, colorScheme, pressed, wi
2127
2132
  if (!allModifiersHold) continue;
2128
2133
  const decls = resolveUtilityNative(parsed, theme);
2129
2134
  if (decls === null) continue;
2135
+ const spec = parsed.modifiers.length;
2130
2136
  for (const d of decls) {
2131
2137
  if (d.property.startsWith("__")) {
2132
2138
  continue;
2133
2139
  }
2134
2140
  if (d.property === "shadow-offset-x" || d.property === "shadow-offset-y") {
2141
+ if (spec < (keySpecificity.get("shadowOffset") ?? 0)) continue;
2135
2142
  const n = Number(d.value);
2136
2143
  if (!Number.isFinite(n)) continue;
2137
2144
  if (shadowOffset === null) shadowOffset = {};
2138
2145
  shadowOffset[d.property === "shadow-offset-x" ? "width" : "height"] = n;
2146
+ keySpecificity.set("shadowOffset", spec);
2139
2147
  continue;
2140
2148
  }
2141
2149
  if (d.property === "transform-op-none") {
2142
2150
  transformOps.clear();
2151
+ transformSpecificity.clear();
2143
2152
  continue;
2144
2153
  }
2145
2154
  const opKey = TRANSFORM_OP_KEYS.get(d.property);
2146
2155
  if (opKey !== void 0) {
2156
+ if (spec < (transformSpecificity.get(opKey) ?? 0)) continue;
2147
2157
  const { value: value2, warning: warning2 } = rnStyleValue(d.property, d.value);
2148
2158
  if (warning2 !== null) warnings.push(warning2);
2149
- if (value2 !== null) transformOps.set(opKey, value2);
2159
+ if (value2 !== null) {
2160
+ transformOps.set(opKey, value2);
2161
+ transformSpecificity.set(opKey, spec);
2162
+ }
2150
2163
  continue;
2151
2164
  }
2165
+ const key = kebabToCamel(d.property);
2166
+ if (spec < (keySpecificity.get(key) ?? 0)) continue;
2152
2167
  const { value, warning } = rnStyleValue(d.property, d.value);
2153
2168
  if (warning !== null) warnings.push(warning);
2154
- if (value !== null) style[kebabToCamel(d.property)] = value;
2169
+ if (value !== null) {
2170
+ style[key] = value;
2171
+ keySpecificity.set(key, spec);
2172
+ }
2155
2173
  }
2156
2174
  }
2157
2175
  if (shadowOffset !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kbach/react-native",
3
- "version": "1.0.0-beta.36",
3
+ "version": "1.0.0-beta.38",
4
4
  "description": "Kbach's utility-class styling engine for React Native — the same class names as @kbach/react, resolved to a StyleSheet-ready style object via a Kotlin/JNI TurboModule (native), the same WASM engine @kbach/react uses (Expo Web / react-native-web), or a pure-JS fallback engine (Expo Go). Ships its own native Android module — one npm install, no separate native package. Beta. Native is Android-only (no iOS bridge yet); works with the React Native CLI, an Expo dev client, AND Expo Go (a reduced-coverage pure-JS fallback, since Expo Go can't load any custom native module).",
5
5
  "keywords": [
6
6
  "css",