@hzblj/kara-sx 0.0.0-stage → 0.2.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 (79) hide show
  1. package/README.md +7 -2
  2. package/lib/module/flatten-sx.js +59 -0
  3. package/lib/module/flatten-sx.js.map +1 -0
  4. package/lib/module/index.js +19 -0
  5. package/lib/module/index.js.map +1 -0
  6. package/lib/module/package.json +1 -0
  7. package/lib/module/resolve-cache.js +79 -0
  8. package/lib/module/resolve-cache.js.map +1 -0
  9. package/lib/module/responsive-sx.js +66 -0
  10. package/lib/module/responsive-sx.js.map +1 -0
  11. package/lib/module/responsive.types.js +4 -0
  12. package/lib/module/responsive.types.js.map +1 -0
  13. package/lib/module/sx-key.js +25 -0
  14. package/lib/module/sx-key.js.map +1 -0
  15. package/lib/module/sx-keys.js +46 -0
  16. package/lib/module/sx-keys.js.map +1 -0
  17. package/lib/module/sx-padding.js +26 -0
  18. package/lib/module/sx-padding.js.map +1 -0
  19. package/lib/module/sx-to-modifiers.android.js +168 -0
  20. package/lib/module/sx-to-modifiers.android.js.map +1 -0
  21. package/lib/module/sx-to-modifiers.ios.js +266 -0
  22. package/lib/module/sx-to-modifiers.ios.js.map +1 -0
  23. package/lib/module/sx-to-style.js +137 -0
  24. package/lib/module/sx-to-style.js.map +1 -0
  25. package/lib/module/sx.types.js +4 -0
  26. package/lib/module/sx.types.js.map +1 -0
  27. package/lib/module/use-sx-modifiers.android.js +30 -0
  28. package/lib/module/use-sx-modifiers.android.js.map +1 -0
  29. package/lib/module/use-sx-modifiers.ios.js +30 -0
  30. package/lib/module/use-sx-modifiers.ios.js.map +1 -0
  31. package/lib/module/use-sx.js +30 -0
  32. package/lib/module/use-sx.js.map +1 -0
  33. package/lib/module/value-key.js +16 -0
  34. package/lib/module/value-key.js.map +1 -0
  35. package/lib/module/view-sx-to-style.js +159 -0
  36. package/lib/module/view-sx-to-style.js.map +1 -0
  37. package/lib/module/view-sx.types.js +4 -0
  38. package/lib/module/view-sx.types.js.map +1 -0
  39. package/lib/module/with-sx.js +50 -0
  40. package/lib/module/with-sx.js.map +1 -0
  41. package/lib/typescript/flatten-sx.d.ts +15 -0
  42. package/lib/typescript/index.d.ts +16 -0
  43. package/lib/typescript/resolve-cache.d.ts +14 -0
  44. package/lib/typescript/responsive-sx.d.ts +80 -0
  45. package/lib/typescript/responsive.types.d.ts +23 -0
  46. package/lib/typescript/sx-key.d.ts +7 -0
  47. package/lib/typescript/sx-keys.d.ts +6 -0
  48. package/lib/typescript/sx-padding.d.ts +5 -0
  49. package/lib/typescript/sx-to-modifiers.d.ts +1 -0
  50. package/lib/typescript/sx-to-modifiers.ios.d.ts +4 -0
  51. package/lib/typescript/sx-to-style.d.ts +4 -0
  52. package/lib/typescript/sx.types.d.ts +182 -0
  53. package/lib/typescript/use-sx-modifiers.d.ts +1 -0
  54. package/lib/typescript/use-sx-modifiers.ios.d.ts +14 -0
  55. package/lib/typescript/use-sx.d.ts +15 -0
  56. package/lib/typescript/value-key.d.ts +7 -0
  57. package/lib/typescript/view-sx-to-style.d.ts +17 -0
  58. package/lib/typescript/view-sx.types.d.ts +80 -0
  59. package/lib/typescript/with-sx.d.ts +28 -0
  60. package/package.json +78 -4
  61. package/src/flatten-sx.ts +70 -0
  62. package/src/index.ts +16 -0
  63. package/src/resolve-cache.ts +105 -0
  64. package/src/responsive-sx.ts +75 -0
  65. package/src/responsive.types.ts +23 -0
  66. package/src/sx-key.ts +32 -0
  67. package/src/sx-keys.ts +47 -0
  68. package/src/sx-padding.ts +27 -0
  69. package/src/sx-to-modifiers.android.ts +224 -0
  70. package/src/sx-to-modifiers.ios.ts +322 -0
  71. package/src/sx-to-style.ts +142 -0
  72. package/src/sx.types.ts +215 -0
  73. package/src/use-sx-modifiers.android.ts +29 -0
  74. package/src/use-sx-modifiers.ios.ts +29 -0
  75. package/src/use-sx.ts +34 -0
  76. package/src/value-key.ts +18 -0
  77. package/src/view-sx-to-style.ts +180 -0
  78. package/src/view-sx.types.ts +87 -0
  79. package/src/with-sx.tsx +51 -0
@@ -0,0 +1,17 @@
1
+ import { type Theme } from '@hzblj/kara-theme';
2
+ import type { ViewStyle } from 'react-native';
3
+ import type { ResponsiveViewSx } from './responsive-sx';
4
+ import type { ViewSx } from './view-sx.types';
5
+ /**
6
+ * Every key the RN style engine owns — what `sx()` and `useSx` split off a component's props. Built
7
+ * from `SX_CORE_KEYS` rather than `SX_KEYS` on purpose: including the `ios`/`android` buckets would
8
+ * have `Box` strip them off its props and drop them, since a React Native style has nothing to
9
+ * resolve them into.
10
+ */
11
+ export declare const VIEW_SX_KEYS: readonly (keyof ViewSx & string)[];
12
+ export declare const viewSxToStyle: (sx: ViewSx, theme: Theme) => ViewStyle;
13
+ /**
14
+ * `split` hands back a `ResponsiveViewSx`, because that is what a component's props hold — `resolve`
15
+ * only accepts the flat `ViewSx` that `flattenSx` turns those into, so the two cannot be crossed.
16
+ */
17
+ export declare const viewSxCache: import("./resolve-cache").SxCache<ViewSx, ViewStyle, ResponsiveViewSx>;
@@ -0,0 +1,80 @@
1
+ import type { RadiiSize, SpaceSize } from '@hzblj/kara-theme';
2
+ import type { ViewStyle } from 'react-native';
3
+ import type { StyleSx, SxDimension } from './sx.types';
4
+ /**
5
+ * Corner radii cascade the way padding does: a single corner beats its edge pair,
6
+ * which beats `borderRadius`. So `borderRadius="medium" borderBottomRadius="none"`
7
+ * rounds the top two corners only.
8
+ *
9
+ * Exported so `CORNER_KEYS` can be held to exactly these keys.
10
+ */
11
+ export type ViewRadiiSx = {
12
+ borderBottomLeftRadius?: RadiiSize;
13
+ borderBottomRadius?: RadiiSize;
14
+ borderBottomRightRadius?: RadiiSize;
15
+ borderLeftRadius?: RadiiSize;
16
+ borderRightRadius?: RadiiSize;
17
+ borderTopLeftRadius?: RadiiSize;
18
+ borderTopRadius?: RadiiSize;
19
+ borderTopRightRadius?: RadiiSize;
20
+ };
21
+ /** Per-side widths draw only the edges you name — a one-sided hairline divider needs just one. */
22
+ type ViewBorderSx = {
23
+ borderBottomWidth?: number;
24
+ borderLeftWidth?: number;
25
+ borderRightWidth?: number;
26
+ borderStyle?: ViewStyle['borderStyle'];
27
+ borderTopWidth?: number;
28
+ };
29
+ /** `minW`/`minH` live in `StyleSx` — every target can express those, so they are not view-only. */
30
+ type ViewSizingSx = {
31
+ aspectRatio?: number;
32
+ maxH?: SxDimension;
33
+ maxW?: SxDimension;
34
+ };
35
+ /**
36
+ * `grow` is deliberately React Native only. Compose's `weight` is the same proportional share of the
37
+ * leftover space, but SwiftUI has no per-child weight at all — its nearest move is an infinite `maxWidth`,
38
+ * which splits the space equally and ignores the ratio. So the modifier paths expose it as
39
+ * `android={{weight}}` and `ios={{layoutPriority}}`, which is what each of them actually does.
40
+ */
41
+ type ViewFlexSx = {
42
+ align?: ViewStyle['alignItems'];
43
+ alignContent?: ViewStyle['alignContent'];
44
+ alignSelf?: ViewStyle['alignSelf'];
45
+ basis?: SxDimension;
46
+ columnGap?: SpaceSize;
47
+ flex?: number;
48
+ flexDirection?: ViewStyle['flexDirection'];
49
+ gap?: SpaceSize;
50
+ grow?: number;
51
+ justify?: ViewStyle['justifyContent'];
52
+ rowGap?: SpaceSize;
53
+ shrink?: number;
54
+ wrap?: ViewStyle['flexWrap'];
55
+ };
56
+ /** Margins mirror the padding scale: `m` all round, `mx`/`my` per axis, `mt`/`mr`/`mb`/`ml` per side. */
57
+ type ViewMarginSx = {
58
+ m?: SpaceSize;
59
+ mb?: SpaceSize;
60
+ ml?: SpaceSize;
61
+ mr?: SpaceSize;
62
+ mt?: SpaceSize;
63
+ mx?: SpaceSize;
64
+ my?: SpaceSize;
65
+ };
66
+ type ViewLayoutSx = {
67
+ bottom?: SxDimension;
68
+ display?: ViewStyle['display'];
69
+ left?: SxDimension;
70
+ overflow?: ViewStyle['overflow'];
71
+ position?: ViewStyle['position'];
72
+ right?: SxDimension;
73
+ top?: SxDimension;
74
+ };
75
+ /**
76
+ * The widest `sx` surface: everything a React Native view can be styled with. `Sx` is the subset the
77
+ * SwiftUI and Compose modifier paths can also express, so only this one reaches the RN style engine.
78
+ */
79
+ export type ViewSx = StyleSx & ViewBorderSx & ViewFlexSx & ViewLayoutSx & ViewMarginSx & ViewRadiiSx & ViewSizingSx;
80
+ export {};
@@ -0,0 +1,28 @@
1
+ import { type ComponentPropsWithRef, type ElementType } from 'react';
2
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
3
+ import type { ResponsiveViewSx } from './responsive-sx';
4
+ /**
5
+ * `sx()` resolves a view style; React Native's text and image styles accept those keys too, so this
6
+ * covers every flavour of `style` a wrapped component might declare.
7
+ */
8
+ type SxStyle = StyleProp<TextStyle | ViewStyle>;
9
+ /** The wrapped component's own props — ref included — with the whole `sx` surface added. */
10
+ export type SxProps<TProps> = Omit<TProps, 'style' | keyof ResponsiveViewSx> & ResponsiveViewSx & {
11
+ style?: SxStyle;
12
+ };
13
+ /**
14
+ * Turns any style-taking component into an `sx` one: the `sx` props are split off, resolved against
15
+ * the theme and handed over as `style`. An explicit `style` still wins — it is merged on top.
16
+ *
17
+ * Props come from `ComponentPropsWithRef`, so a `ref` survives the wrapping. The component has to
18
+ * accept a `style`; that cannot be a constraint here, because narrowing `TComponent` past
19
+ * `ElementType` is what breaks the ref inference.
20
+ *
21
+ * Memoised because the point of the cache is a stable style object — without `memo` a parent
22
+ * re-render would walk the child anyway and throw that stability away.
23
+ */
24
+ export declare const sx: <TComponent extends ElementType>(Component: TComponent) => import("react").MemoExoticComponent<{
25
+ ({ style, ...props }: SxProps<ComponentPropsWithRef<TComponent>>): import("react").JSX.Element;
26
+ displayName: string;
27
+ }>;
28
+ export {};
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "@hzblj/kara-sx",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "The token-typed sx styling engine behind @hzblj/kara-ui: one surface resolved into SwiftUI modifiers, Compose modifiers or a React Native style.",
5
+ "author": "Jan Blazej",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hzblj/kara-ui.git",
10
+ "directory": "packages/sx"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/hzblj/kara-ui/issues"
14
+ },
15
+ "homepage": "https://github.com/hzblj/kara-ui/tree/main/packages/sx#readme",
16
+ "keywords": [
17
+ "react-native",
18
+ "expo",
19
+ "kara-ui"
20
+ ],
21
+ "main": "./lib/module/index.js",
22
+ "types": "./lib/typescript/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "source": "./src/index.ts",
26
+ "types": "./lib/typescript/index.d.ts",
27
+ "default": "./lib/module/index.js"
28
+ },
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "lib",
34
+ "!**/*.stories.tsx",
35
+ "!**/contract.check.ts",
36
+ "!**/story-decorators.tsx",
37
+ "!**/__tests__"
38
+ ],
39
+ "scripts": {
40
+ "build": "bob build && tsc -p ./tsconfig.build.json && node ../../scripts/fallback-declarations.mjs",
41
+ "clean": "rm -rf lib",
42
+ "prepack": "yarn run -T turbo run build --filter=$npm_package_name...",
43
+ "lint": "yarn run -T concurrently \"yarn biome:check\" \"yarn tsc:check\"",
44
+ "lint:fix": "yarn run -T biome check --write .",
45
+ "biome:check": "yarn run -T biome check .",
46
+ "tsc:check": "yarn run -T concurrently \"tsc -p ./tsconfig.ios.json\" \"tsc -p ./tsconfig.android.json\""
47
+ },
48
+ "react-native-builder-bob": {
49
+ "source": "src",
50
+ "output": "lib",
51
+ "exclude": "{**/__tests__/**,**/__fixtures__/**,**/__mocks__/**,**/*.stories.tsx,**/contract.check.ts,**/story-decorators.tsx}",
52
+ "targets": [
53
+ [
54
+ "module",
55
+ {
56
+ "esm": true
57
+ }
58
+ ]
59
+ ]
60
+ },
61
+ "dependencies": {
62
+ "@hzblj/kara-theme": "workspace:^"
63
+ },
64
+ "peerDependencies": {
65
+ "@expo/ui": "~57.0.0",
66
+ "react": "*",
67
+ "react-native": "*"
68
+ },
69
+ "devDependencies": {
70
+ "@expo/ui": "~57.0.18",
71
+ "@types/react": "~19.2.18",
72
+ "react": "19.2.3",
73
+ "react-native": "0.86.3",
74
+ "react-native-builder-bob": "^0.43.0",
75
+ "typescript": "~7.0.2"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public"
79
+ }
80
+ }
@@ -0,0 +1,70 @@
1
+ import {type ActiveBreakpoint, BREAKPOINT_ORDER, type Breakpoint} from '@hzblj/kara-theme'
2
+
3
+ import type {ResponsiveOf} from './responsive.types'
4
+
5
+ /** The runtime shape of a `Responsive` object — what `isResponsive` narrows an `sx` value to. */
6
+ type ResponsiveValue = {base: unknown} & Partial<Record<Breakpoint, unknown>>
7
+
8
+ /**
9
+ * No responsive-capable key takes an object value — each one is a token, a dimension, a number or a
10
+ * boolean — so `base` being present identifies a per-breakpoint value outright, with nothing it could
11
+ * be confused for. That is exactly why the visual keys stay scalar: `scale` and the platform buckets
12
+ * *are* objects, and admitting them would make this test ambiguous.
13
+ */
14
+ const isResponsive = (value: unknown): value is ResponsiveValue =>
15
+ typeof value === 'object' && value !== null && 'base' in value
16
+
17
+ /** Mobile-first: the widest breakpoint declared at or below the active one, falling back to `base`. */
18
+ const valueAt = (value: ResponsiveValue, active: ActiveBreakpoint): unknown => {
19
+ let resolved = value.base
20
+
21
+ if (active === 'base') {
22
+ return resolved
23
+ }
24
+
25
+ for (const name of BREAKPOINT_ORDER) {
26
+ const declared = value[name]
27
+
28
+ if (declared !== undefined) {
29
+ resolved = declared
30
+ }
31
+
32
+ if (name === active) {
33
+ break
34
+ }
35
+ }
36
+
37
+ return resolved
38
+ }
39
+
40
+ /**
41
+ * Collapses a responsive `sx` into the flat one the active breakpoint asks for.
42
+ *
43
+ * Deliberately upstream of the caches rather than inside them: `resolve-cache` and `sxKey` only ever
44
+ * see flat values, so neither their one-level key flattening nor their key space has to grow a
45
+ * breakpoint dimension — and two devices that land on the same flat `sx` still share its entry. A
46
+ * breakpoint folded into the cache key instead would have doubled the entries for every `sx` in the
47
+ * app, responsive or not.
48
+ *
49
+ * Hands back the very object it was given when nothing in it is responsive, which is the overwhelming
50
+ * case: `sx()` wraps every view, and those call sites paying nothing is what keeps it affordable.
51
+ */
52
+ export const flattenSx = <TSx extends object, TKey extends keyof TSx & string>(
53
+ sx: ResponsiveOf<TSx, TKey>,
54
+ keys: readonly TKey[],
55
+ active: ActiveBreakpoint
56
+ ): TSx => {
57
+ const source = sx as Record<string, unknown>
58
+ let flattened: Record<string, unknown> | undefined
59
+
60
+ for (const name of keys) {
61
+ const value = source[name]
62
+
63
+ if (isResponsive(value)) {
64
+ flattened ??= {...source}
65
+ flattened[name] = valueAt(value, active)
66
+ }
67
+ }
68
+
69
+ return (flattened ?? source) as TSx
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export * from './flatten-sx'
2
+ export * from './resolve-cache'
3
+ export * from './responsive.types'
4
+ export * from './responsive-sx'
5
+ export * from './sx.types'
6
+ export * from './sx-key'
7
+ export * from './sx-keys'
8
+ export * from './sx-padding'
9
+ export * from './sx-to-modifiers'
10
+ export * from './sx-to-style'
11
+ export * from './use-sx'
12
+ export * from './use-sx-modifiers'
13
+ export * from './value-key'
14
+ export * from './view-sx.types'
15
+ export * from './view-sx-to-style'
16
+ export * from './with-sx'
@@ -0,0 +1,105 @@
1
+ import type {Theme} from '@hzblj/kara-theme'
2
+
3
+ import {valueKey} from './value-key'
4
+
5
+ type StyleResolver<TProps, TStyle> = (props: TProps, theme: Theme) => TStyle
6
+
7
+ /**
8
+ * `resolve` takes `TProps`, `split` hands back `TInput`. The same surface, except that props reach
9
+ * `split` straight off a component with responsive values still in them, and reach `resolve` only once
10
+ * `flattenSx` has collapsed those — so a caller who splits has to flatten before it can resolve.
11
+ * Defaults to `TProps`, for a cache whose surface has nothing responsive in it.
12
+ */
13
+ export type SxCache<TProps, TStyle, TInput = TProps> = {
14
+ resolve: (props: TProps, theme: Theme) => TStyle
15
+ split: <T extends Record<string, unknown>>(props: T) => [TInput, Record<string, unknown>]
16
+ }
17
+
18
+ /**
19
+ * Token-typed values keep the key space small, but `sx` also takes free-form numbers — an `sx`
20
+ * driven per frame would otherwise grow the map for the life of the app. Past the cap the whole map
21
+ * resets: re-resolving is cheap, and an LRU would tax every hit to soften a rare misuse.
22
+ */
23
+ const MAX_CACHE_ENTRIES = 1024
24
+
25
+ const identities = new WeakMap<object, string>()
26
+
27
+ let nextIdentity = 0
28
+
29
+ /**
30
+ * The variant alone does not identify a theme's colours. On Android the palette resolver closes over a Material
31
+ * scheme generated at runtime, which can change while the variant stays put — a new seed, or a wallpaper change on
32
+ * Android 12+ — and on both platforms the app's own overrides decide what a token's hex is. Keying on the identity of
33
+ * the resolver *and* of the palette is what stops a resolved style outliving the colours it was built from. Both are
34
+ * memoised by the provider, so a steady theme costs one entry each.
35
+ */
36
+ const identity = (value: object): string => {
37
+ const existing = identities.get(value)
38
+
39
+ if (existing) {
40
+ return existing
41
+ }
42
+
43
+ nextIdentity += 1
44
+
45
+ const id = `#${nextIdentity}`
46
+ identities.set(value, id)
47
+
48
+ return id
49
+ }
50
+
51
+ export const createSxCache = <TProps extends Record<string, unknown>, TStyle, TInput = TProps>(
52
+ keys: readonly (keyof TProps & string)[],
53
+ resolver: StyleResolver<TProps, TStyle>
54
+ ): SxCache<TProps, TStyle, TInput> => {
55
+ const cache = new Map<string, TStyle>()
56
+ const keySet = new Set<string>(keys)
57
+
58
+ const cacheKey = (props: TProps, theme: Theme) => {
59
+ let key = `${theme.dark ? 'd' : 'l'}${identity(theme.resolver)}${identity(theme.palette)}`
60
+
61
+ for (const name of keys) {
62
+ const value = props[name]
63
+
64
+ if (value !== undefined) {
65
+ key += `|${name}:${valueKey(value)}`
66
+ }
67
+ }
68
+
69
+ return key
70
+ }
71
+
72
+ return {
73
+ resolve: (props, theme) => {
74
+ const key = cacheKey(props, theme)
75
+ const cached = cache.get(key)
76
+
77
+ if (cached) {
78
+ return cached
79
+ }
80
+
81
+ if (cache.size >= MAX_CACHE_ENTRIES) {
82
+ cache.clear()
83
+ }
84
+
85
+ const style = resolver(props, theme)
86
+ cache.set(key, style)
87
+
88
+ return style
89
+ },
90
+ split: props => {
91
+ const owned: Record<string, unknown> = {}
92
+ const rest: Record<string, unknown> = {}
93
+
94
+ for (const name of Object.keys(props)) {
95
+ if (keySet.has(name)) {
96
+ owned[name] = props[name]
97
+ } else {
98
+ rest[name] = props[name]
99
+ }
100
+ }
101
+
102
+ return [owned as TInput, rest]
103
+ },
104
+ }
105
+ }
@@ -0,0 +1,75 @@
1
+ import type {ResponsiveOf} from './responsive.types'
2
+ import type {Sx, SxCore} from './sx.types'
3
+ import type {ViewSx} from './view-sx.types'
4
+
5
+ /**
6
+ * Layout keys only, and deliberately so: a wider screen wants a different gap, padding or column
7
+ * count — not a different colour. `bg`, `shadow`, the radii, `opacity`, the transforms and both
8
+ * platform buckets therefore stay scalar. Every key admitted here is one more that `flattenSx` walks
9
+ * on every render, for a kind of change a design system should not be making anyway.
10
+ *
11
+ * `Partial` because this is a subset by design; what the `satisfies` buys is the other direction — a
12
+ * key renamed in `SxCore` stops compiling here instead of silently going non-responsive.
13
+ */
14
+ const SX_RESPONSIVE_KEY_RECORD = {
15
+ h: true,
16
+ minH: true,
17
+ minW: true,
18
+ p: true,
19
+ pb: true,
20
+ pl: true,
21
+ pr: true,
22
+ pt: true,
23
+ px: true,
24
+ py: true,
25
+ tall: true,
26
+ w: true,
27
+ wide: true,
28
+ } as const satisfies Partial<Record<keyof SxCore, true>>
29
+
30
+ export type ResponsiveSxKey = keyof typeof SX_RESPONSIVE_KEY_RECORD
31
+
32
+ /** What `useSxModifiers` flattens before the SwiftUI and Compose paths see it. */
33
+ export const SX_RESPONSIVE_KEYS = Object.keys(SX_RESPONSIVE_KEY_RECORD) as readonly ResponsiveSxKey[]
34
+
35
+ /** The core keys plus what only the React Native style path has: margins, gaps, flex, sizing maxima. */
36
+ const VIEW_RESPONSIVE_KEY_RECORD = {
37
+ ...SX_RESPONSIVE_KEY_RECORD,
38
+ align: true,
39
+ alignContent: true,
40
+ alignSelf: true,
41
+ basis: true,
42
+ columnGap: true,
43
+ display: true,
44
+ flex: true,
45
+ flexDirection: true,
46
+ gap: true,
47
+ grow: true,
48
+ justify: true,
49
+ m: true,
50
+ maxH: true,
51
+ maxW: true,
52
+ mb: true,
53
+ ml: true,
54
+ mr: true,
55
+ mt: true,
56
+ mx: true,
57
+ my: true,
58
+ rowGap: true,
59
+ shrink: true,
60
+ wrap: true,
61
+ } as const satisfies Partial<Record<keyof ViewSx, true>>
62
+
63
+ export type ResponsiveViewSxKey = keyof typeof VIEW_RESPONSIVE_KEY_RECORD
64
+
65
+ /** What `useSx` flattens before the style cache sees it. */
66
+ export const VIEW_RESPONSIVE_KEYS = Object.keys(VIEW_RESPONSIVE_KEY_RECORD) as readonly ResponsiveViewSxKey[]
67
+
68
+ /**
69
+ * The native modifier surface as a component takes it — `Sx` is the same surface once a breakpoint
70
+ * has been picked, which is what every resolver and cache downstream of `flattenSx` works with.
71
+ */
72
+ export type ResponsiveSx = ResponsiveOf<Sx, ResponsiveSxKey>
73
+
74
+ /** `ViewSx` as a component takes it, standing to it as `ResponsiveSx` does to `Sx`. */
75
+ export type ResponsiveViewSx = ResponsiveOf<ViewSx, ResponsiveViewSxKey>
@@ -0,0 +1,23 @@
1
+ import type {Breakpoint} from '@hzblj/kara-theme'
2
+
3
+ /**
4
+ * A value that may differ per breakpoint: the plain value, or one keyed by breakpoint.
5
+ *
6
+ * `base` is mandatory. Partly because the cascade needs a floor to fall back to, and partly because
7
+ * an `sx` that applies nothing on the narrowest phone is a bug rather than a style.
8
+ *
9
+ * The cascade runs mobile-first — a breakpoint holds until a wider one overrides it. So
10
+ * `{base: 's8', md: 's16'}` pads by 8 below `md` and by 16 from `md` up, `lg` and `xl` included.
11
+ */
12
+ export type Responsive<TValue> = TValue | ({base: TValue} & Partial<Record<Breakpoint, TValue>>)
13
+
14
+ /**
15
+ * Widens the named keys of a flat `sx` surface to `Responsive`, leaving the rest alone.
16
+ *
17
+ * A transform over the flat type rather than a hand-written twin, so a key added to `Sx` or `ViewSx`
18
+ * needs no second edit here — and `NonNullable`, so the optionality stays on the prop and cannot
19
+ * sneak into `base`, which would defeat the point of requiring it.
20
+ */
21
+ export type ResponsiveOf<TSx, TKey extends keyof TSx> = Omit<TSx, TKey> & {
22
+ [K in TKey]?: Responsive<NonNullable<TSx[K]>>
23
+ }
package/src/sx-key.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type {Sx} from './sx.types'
2
+ import {SX_BUCKET_KEYS, SX_CORE_KEYS} from './sx-keys'
3
+ import {valueKey} from './value-key'
4
+
5
+ /**
6
+ * Keys sorted, because two buckets holding the same values can list them in a different order and
7
+ * still have to key alike. Object configs flatten through `valueKey`, one level — which is all the
8
+ * `Bucket` constraint admits. `modifiers` is skipped: it holds opaque configs, so no string stands
9
+ * in for it — `useSxModifiers` depends on that array itself alongside this key.
10
+ */
11
+ const bucketKey = (bucket: Record<string, unknown> | undefined): string => {
12
+ if (!bucket) {
13
+ return ''
14
+ }
15
+
16
+ return Object.keys(bucket)
17
+ .filter(name => name !== 'modifiers')
18
+ .sort()
19
+ .map(name => `${name}:${valueKey(bucket[name])}`)
20
+ .join(',')
21
+ }
22
+
23
+ /**
24
+ * A stable `useMemo` dependency for an `Sx`. Atoms take theirs from a rest spread, so the object is
25
+ * a fresh identity on every render even when nothing in it changed — the values flattened into one
26
+ * string say exactly as much as the object does.
27
+ */
28
+ export const sxKey = (sx: Sx): string =>
29
+ [
30
+ ...SX_CORE_KEYS.map(name => (sx[name] === undefined ? '' : valueKey(sx[name]))),
31
+ ...SX_BUCKET_KEYS.map(name => bucketKey(sx[name])),
32
+ ].join('|')
package/src/sx-keys.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type {Sx, SxCore} from './sx.types'
2
+
3
+ /**
4
+ * A `Record` rather than a bare array, because `satisfies` on an array only checks membership —
5
+ * here the compiler holds the list to `SxCore` in both directions, so a key added to the type but
6
+ * missing from this list (which `split` would then leak into `rest`) refuses to compile.
7
+ */
8
+ const SX_CORE_KEY_RECORD = {
9
+ bg: true,
10
+ blur: true,
11
+ borderColor: true,
12
+ borderRadius: true,
13
+ borderWidth: true,
14
+ h: true,
15
+ minH: true,
16
+ minW: true,
17
+ offsetX: true,
18
+ offsetY: true,
19
+ opacity: true,
20
+ p: true,
21
+ pb: true,
22
+ pl: true,
23
+ pr: true,
24
+ pt: true,
25
+ px: true,
26
+ py: true,
27
+ rotate: true,
28
+ scale: true,
29
+ shadow: true,
30
+ tall: true,
31
+ w: true,
32
+ wide: true,
33
+ zIndex: true,
34
+ } as const satisfies Record<keyof SxCore, true>
35
+
36
+ /** The keys every target shares. The React Native style path takes these and none of the buckets. */
37
+ export const SX_CORE_KEYS = Object.keys(SX_CORE_KEY_RECORD) as readonly (keyof SxCore)[]
38
+
39
+ const SX_BUCKET_KEY_RECORD = {
40
+ android: true,
41
+ ios: true,
42
+ } as const satisfies Record<Exclude<keyof Sx, keyof SxCore>, true>
43
+
44
+ /** Kept apart from `SX_CORE_KEYS` so the React Native path can take the core without them. */
45
+ export const SX_BUCKET_KEYS = Object.keys(SX_BUCKET_KEY_RECORD) as readonly Exclude<keyof Sx, keyof SxCore>[]
46
+
47
+ export const SX_KEYS: readonly (keyof Sx)[] = [...SX_CORE_KEYS, ...SX_BUCKET_KEYS]
@@ -0,0 +1,27 @@
1
+ import {spacing} from '@hzblj/kara-theme'
2
+
3
+ import type {Sx, SxPadding} from './sx.types'
4
+
5
+ const size = (token: SpaceKey | undefined) => (token === undefined ? undefined : spacing[token])
6
+
7
+ type SpaceKey = NonNullable<Sx['p']>
8
+
9
+ /** Padding is all this reads, so both `Sx` and the widened `StyleSx` satisfy it. */
10
+ type SxPaddingInput = Pick<Sx, 'p' | 'pb' | 'pl' | 'pr' | 'pt' | 'px' | 'py'>
11
+
12
+ export const sxPadding = (sx: SxPaddingInput): SxPadding | undefined => {
13
+ const all = size(sx.p)
14
+ const horizontal = size(sx.px)
15
+ const vertical = size(sx.py)
16
+
17
+ const top = size(sx.pt) ?? vertical ?? all
18
+ const bottom = size(sx.pb) ?? vertical ?? all
19
+ const leading = size(sx.pl) ?? horizontal ?? all
20
+ const trailing = size(sx.pr) ?? horizontal ?? all
21
+
22
+ if (top === undefined && bottom === undefined && leading === undefined && trailing === undefined) {
23
+ return undefined
24
+ }
25
+
26
+ return {bottom, leading, top, trailing}
27
+ }