@advui/theme 0.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@advui/theme",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Design tokens, color presets and the Tamagui config factory for Adv UI.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/index.d.ts",
21
21
  "default": "./dist/index.js"
22
22
  },
23
+ "./material": {
24
+ "types": "./dist/material.d.ts",
25
+ "default": "./dist/material.js"
26
+ },
23
27
  "./package.json": "./package.json"
24
28
  },
25
29
  "files": [
@@ -31,11 +35,12 @@
31
35
  "access": "public"
32
36
  },
33
37
  "dependencies": {
38
+ "@advui/utils": "0.2.0",
39
+ "@material/material-color-utilities": "0.3.0",
34
40
  "@tamagui/animations-css": "2.7.7",
35
41
  "@tamagui/animations-react-native": "2.7.7",
36
42
  "@tamagui/colors": "2.7.7",
37
- "@tamagui/web": "2.7.7",
38
- "@advui/utils": "0.1.0"
43
+ "@tamagui/web": "2.7.7"
39
44
  },
40
45
  "peerDependencies": {
41
46
  "react": ">=19",
@@ -43,9 +48,9 @@
43
48
  "tamagui": "^2.7.7"
44
49
  },
45
50
  "devDependencies": {
46
- "@types/react": "~19.2.2",
47
51
  "@advui/eslint-config": "0.1.0",
48
52
  "@advui/tsconfig": "0.1.0",
53
+ "@types/react": "~19.2.2",
49
54
  "react": "19.2.3",
50
55
  "react-dom": "19.2.3",
51
56
  "react-native": "0.86.3",
package/src/config.ts CHANGED
@@ -4,19 +4,44 @@ import { type FontFamilies, type FontScale, createUniversalFonts } from './fonts
4
4
  import { media, mediaQueryDefaultActive } from './media'
5
5
  import { type ThemePresetName, themePresets } from './presets'
6
6
  import { shorthands } from './shorthands'
7
- import { type ThemeColorsInput, createThemeColors } from './themes'
8
- import { type RadiusScale, createUniversalTokens } from './tokens'
7
+ import { type GeneratedThemes, type ThemeColorsInput, createThemeColors } from './themes'
8
+ import { type RadiusInput, createUniversalTokens } from './tokens'
9
9
 
10
10
  export interface UniversalConfigOptions {
11
11
  /** Starting color preset. Individual `colors` win over the preset. Default: `indigo`. */
12
12
  preset?: ThemePresetName
13
13
  /** Brand and intent colors — scale names (`'violet'`) or any color string (`'#6366f1'`). */
14
14
  colors?: ThemeColorsInput
15
- /** Corner roundness for every component. Default: `md` (8px base). */
16
- radius?: RadiusScale | number
15
+ /**
16
+ * Corner roundness for every component: a scale name or base in px (default
17
+ * `md`, 8px), or exact values per token, e.g. `{ button: 9999 }`.
18
+ */
19
+ radius?: RadiusInput
17
20
  /** Global type scale. Default: `default`. */
18
21
  fontScale?: FontScale | number
19
22
  fonts?: FontFamilies
23
+ /**
24
+ * Complete light and dark themes, e.g. from `material()` in
25
+ * `@advui/theme/material`. Replaces `preset` and `colors`.
26
+ */
27
+ themes?: GeneratedThemes
28
+ /**
29
+ * Android only: pressing a button, chip, tab or list row shows the native
30
+ * ripple instead of darkening it. `material()` turns it on. Default: false.
31
+ */
32
+ androidRipple?: boolean
33
+ }
34
+
35
+ /** Adv UI settings that are not Tamagui's, such as `androidRipple`. */
36
+ export interface UniversalSettings {
37
+ androidRipple: boolean
38
+ }
39
+
40
+ const defaultSettings: UniversalSettings = { androidRipple: false }
41
+
42
+ /** The Adv UI settings stored on a config from `createUniversalConfig()`. */
43
+ export function getUniversalSettings(config: object): UniversalSettings {
44
+ return (config as { advui?: UniversalSettings }).advui ?? defaultSettings
20
45
  }
21
46
 
22
47
  /**
@@ -28,9 +53,9 @@ export interface UniversalConfigOptions {
28
53
  */
29
54
  export function createUniversalConfig(options: UniversalConfigOptions = {}) {
30
55
  const { preset = 'indigo', colors, radius = 'md', fontScale = 'default', fonts } = options
31
- const themes = createThemeColors({ ...themePresets[preset].colors, ...colors })
56
+ const themes = options.themes ?? createThemeColors({ ...themePresets[preset].colors, ...colors })
32
57
 
33
- return createTamagui({
58
+ const config = createTamagui({
34
59
  tokens: createUniversalTokens({ radius }),
35
60
  themes,
36
61
  fonts: createUniversalFonts({ scale: fontScale, families: fonts }),
@@ -50,6 +75,10 @@ export function createUniversalConfig(options: UniversalConfigOptions = {}) {
50
75
  styleCompat: 'web',
51
76
  },
52
77
  })
78
+ // Stored on the config object, which components reach from anywhere with
79
+ // getConfig(), including native portals that do not carry React context.
80
+ const advui: UniversalSettings = { androidRipple: options.androidRipple ?? false }
81
+ return Object.assign(config, { advui })
53
82
  }
54
83
 
55
84
  export type UniversalConfig = ReturnType<typeof createUniversalConfig>
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { animations } from './animations'
2
- export { createUniversalConfig, type UniversalConfig, type UniversalConfigOptions } from './config'
2
+ export {
3
+ createUniversalConfig,
4
+ getUniversalSettings,
5
+ type UniversalConfig,
6
+ type UniversalConfigOptions,
7
+ type UniversalSettings,
8
+ } from './config'
3
9
  export { createUniversalFonts, fontScales, type FontFamilies, type FontScale } from './fonts'
4
10
  export { breakpoints, media, mediaQueryDefaultActive } from './media'
5
11
  export {
@@ -33,5 +39,8 @@ export {
33
39
  size,
34
40
  space,
35
41
  zIndex,
42
+ type RadiusInput,
43
+ type RadiusOverrides,
36
44
  type RadiusScale,
45
+ type RadiusToken,
37
46
  } from './tokens'
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Material 3 for Adv UI: color roles generated from one seed color with
3
+ * Google's material-color-utilities (the algorithm behind Android dynamic
4
+ * color), plus Material's shape scale and fonts. It lives in its own entry,
5
+ * `@advui/theme/material`, so apps that do not use it never ship the library.
6
+ *
7
+ * Material roles are mapped onto Adv UI's roles, so every component works
8
+ * unchanged: `primaryContainer` → `$primarySoft`, `secondaryContainer` →
9
+ * `$secondary` (Material's tonal button), `outline` → `$input`, and so on.
10
+ */
11
+ import {
12
+ type DynamicScheme,
13
+ Hct,
14
+ SchemeTonalSpot,
15
+ argbFromHex,
16
+ customColor,
17
+ hexFromArgb,
18
+ } from '@material/material-color-utilities'
19
+ import { ensureContrast, parseColor, toHex, withAlpha } from '@advui/utils'
20
+ import type { UniversalConfigOptions } from './config'
21
+ import type { FontFamilies } from './fonts'
22
+ import type { ColorMode, GeneratedThemes, ThemeValues } from './themes'
23
+ import type { RadiusOverrides } from './tokens'
24
+
25
+ /** Material's baseline purple, used when no seed is given. */
26
+ export const MATERIAL_BASELINE_SEED = '#6750A4'
27
+
28
+ export interface MaterialOptions {
29
+ /** Seed color the whole palette is generated from. Default: Material baseline purple. */
30
+ seed?: string
31
+ /**
32
+ * Status colors Material does not define. They are harmonized toward the
33
+ * seed (a slight hue shift) so they sit well with it.
34
+ */
35
+ colors?: { success?: string; warning?: string; info?: string }
36
+ /** Escape hatch: override any generated value per mode. */
37
+ overrides?: Partial<Record<ColorMode, Partial<ThemeValues>>>
38
+ /** Android: show the native ripple when something is pressed. Default: true. */
39
+ androidRipple?: boolean
40
+ }
41
+
42
+ const defaultStatus = { success: '#2e7d32', warning: '#b26a00', info: '#0061a4' }
43
+
44
+ // Material state-layer opacities: hover 8%, focus and press 10%.
45
+ const HOVER = 0.08
46
+ const PRESS = 0.1
47
+
48
+ /** Paints `overlay` over `base` at `opacity`, like a Material state layer (sRGB). */
49
+ function layer(base: string, overlay: string, opacity: number): string {
50
+ const b = parseColor(base)
51
+ const o = parseColor(overlay)
52
+ if (!b || !o) throw new Error(`Invalid color: ${!b ? base : overlay}`)
53
+ const c = (x: number, y: number) => x * (1 - opacity) + y * opacity
54
+ return toHex({ r: c(b.r, o.r), g: c(b.g, o.g), b: c(b.b, o.b), a: 1 })
55
+ }
56
+
57
+ // Neutral tones behind Tamagui's color1…color12 (lightest to strongest text).
58
+ const NEUTRAL_TONES = {
59
+ light: [99, 98, 96, 94, 92, 90, 87, 80, 60, 50, 30, 10],
60
+ dark: [6, 10, 12, 17, 22, 24, 30, 40, 60, 70, 80, 90],
61
+ } as const
62
+
63
+ function buildMode(
64
+ s: DynamicScheme,
65
+ mode: ColorMode,
66
+ status: Record<'success' | 'warning' | 'info', ReturnType<typeof customColor>>,
67
+ ): ThemeValues {
68
+ const hex = hexFromArgb
69
+ const isDark = mode === 'dark'
70
+ const surface = hex(s.surface)
71
+ const onSurface = hex(s.onSurface)
72
+ const popover = hex(s.surfaceContainerHigh)
73
+ const muted = hex(s.surfaceContainerHigh)
74
+ const tone = (step: number) => hex(s.neutralPalette.tone(NEUTRAL_TONES[mode][step]!))
75
+
76
+ const primary = hex(s.primary)
77
+ const onPrimary = hex(s.onPrimary)
78
+ const primaryContainer = hex(s.primaryContainer)
79
+ const onPrimaryContainer = hex(s.onPrimaryContainer)
80
+ const secondaryContainer = hex(s.secondaryContainer)
81
+ const onSecondaryContainer = hex(s.onSecondaryContainer)
82
+ const error = hex(s.error)
83
+ const onError = hex(s.onError)
84
+ const errorContainer = hex(s.errorContainer)
85
+ const onErrorContainer = hex(s.onErrorContainer)
86
+ const outline = hex(s.outline)
87
+ const outlineVariant = hex(s.outlineVariant)
88
+
89
+ const intent = (group: ReturnType<typeof customColor>) => {
90
+ const g = isDark ? group.dark : group.light
91
+ return {
92
+ solid: hex(g.color),
93
+ foreground: hex(g.onColor),
94
+ soft: hex(g.colorContainer),
95
+ softForeground: ensureContrast(hex(g.onColorContainer), hex(g.colorContainer)),
96
+ border: layer(hex(g.colorContainer), hex(g.color), 0.35),
97
+ }
98
+ }
99
+ const success = intent(status.success)
100
+ const warning = intent(status.warning)
101
+ const info = intent(status.info)
102
+
103
+ return {
104
+ background: surface,
105
+ backgroundHover: layer(surface, onSurface, HOVER),
106
+ backgroundPress: layer(surface, onSurface, PRESS),
107
+ backgroundFocus: layer(surface, onSurface, PRESS),
108
+ backgroundStrong: hex(s.surfaceContainer),
109
+ backgroundTransparent: withAlpha(surface, 0),
110
+ color: onSurface,
111
+ colorHover: onSurface,
112
+ colorPress: hex(s.onSurfaceVariant),
113
+ colorFocus: onSurface,
114
+ colorTransparent: withAlpha(onSurface, 0),
115
+ borderColor: outlineVariant,
116
+ borderColorHover: outline,
117
+ borderColorPress: outline,
118
+ borderColorFocus: primary,
119
+ placeholderColor: hex(s.onSurfaceVariant),
120
+ outlineColor: primary,
121
+ shadowColor: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.12)',
122
+ shadowColorStrong: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.24)',
123
+ color1: tone(0),
124
+ color2: tone(1),
125
+ color3: tone(2),
126
+ color4: tone(3),
127
+ color5: tone(4),
128
+ color6: tone(5),
129
+ color7: tone(6),
130
+ color8: tone(7),
131
+ color9: tone(8),
132
+ color10: tone(9),
133
+ color11: tone(10),
134
+ color12: tone(11),
135
+
136
+ foreground: onSurface,
137
+ muted,
138
+ mutedForeground: ensureContrast(hex(s.onSurfaceVariant), muted),
139
+ card: hex(s.surfaceContainerLow),
140
+ cardForeground: onSurface,
141
+ popover,
142
+ popoverForeground: onSurface,
143
+ overlay: withAlpha(hex(s.scrim), 0.32),
144
+
145
+ border: outlineVariant,
146
+ borderStrong: outline,
147
+ input: outline,
148
+ ring: primary,
149
+
150
+ primary,
151
+ primaryHover: layer(primary, onPrimary, HOVER),
152
+ primaryPress: layer(primary, onPrimary, PRESS),
153
+ primaryForeground: onPrimary,
154
+ primaryText: ensureContrast(primary, surface),
155
+ inversePrimary: ensureContrast(hex(s.inversePrimary), onSurface),
156
+ primarySoft: primaryContainer,
157
+ primarySoftHover: layer(primaryContainer, onPrimaryContainer, HOVER),
158
+ primarySoftForeground: onPrimaryContainer,
159
+ // Material's tonal button.
160
+ secondary: secondaryContainer,
161
+ secondaryHover: layer(secondaryContainer, onSecondaryContainer, HOVER),
162
+ secondaryPress: layer(secondaryContainer, onSecondaryContainer, PRESS),
163
+ secondaryForeground: onSecondaryContainer,
164
+ // Hover highlight for menu items and ghost buttons: a state layer over the
165
+ // menu surface, so it reads on both the page and the menu.
166
+ accent: layer(popover, onSurface, HOVER),
167
+ accentHover: layer(popover, onSurface, 0.12),
168
+ accentForeground: onSurface,
169
+
170
+ destructive: error,
171
+ destructiveHover: layer(error, onError, HOVER),
172
+ destructivePress: layer(error, onError, PRESS),
173
+ destructiveForeground: onError,
174
+ destructiveSoft: errorContainer,
175
+ destructiveSoftForeground: onErrorContainer,
176
+ success: success.solid,
177
+ successForeground: success.foreground,
178
+ successSoft: success.soft,
179
+ successSoftForeground: success.softForeground,
180
+ successBorder: success.border,
181
+ warning: warning.solid,
182
+ warningForeground: warning.foreground,
183
+ warningSoft: warning.soft,
184
+ warningSoftForeground: warning.softForeground,
185
+ warningBorder: warning.border,
186
+ error,
187
+ errorForeground: onError,
188
+ errorSoft: errorContainer,
189
+ errorSoftForeground: onErrorContainer,
190
+ errorBorder: layer(errorContainer, error, 0.35),
191
+ info: info.solid,
192
+ infoForeground: info.foreground,
193
+ infoSoft: info.soft,
194
+ infoSoftForeground: info.softForeground,
195
+ infoBorder: info.border,
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Light and dark themes with Material 3 color roles (the "tonal spot" scheme
201
+ * Android uses for dynamic color), generated from `seed`.
202
+ */
203
+ export function createMaterialThemes(options: MaterialOptions = {}): GeneratedThemes {
204
+ const seed = options.seed ?? MATERIAL_BASELINE_SEED
205
+ const source = argbFromHex(toHex(seed))
206
+ const colors = { ...defaultStatus, ...options.colors }
207
+ const status = {
208
+ success: customColor(source, {
209
+ name: 'success',
210
+ value: argbFromHex(colors.success),
211
+ blend: true,
212
+ }),
213
+ warning: customColor(source, {
214
+ name: 'warning',
215
+ value: argbFromHex(colors.warning),
216
+ blend: true,
217
+ }),
218
+ info: customColor(source, { name: 'info', value: argbFromHex(colors.info), blend: true }),
219
+ }
220
+ const scheme = (isDark: boolean) => new SchemeTonalSpot(Hct.fromInt(source), isDark, 0)
221
+ return {
222
+ light: { ...buildMode(scheme(false), 'light', status), ...options.overrides?.light },
223
+ dark: { ...buildMode(scheme(true), 'dark', status), ...options.overrides?.dark },
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Material 3 shape scale on Adv UI's radius tokens: pill buttons, 4px fields,
229
+ * 12px cards and 28px dialogs and sheets.
230
+ */
231
+ export const materialShape: RadiusOverrides = {
232
+ xs: 2,
233
+ sm: 4,
234
+ md: 4,
235
+ lg: 8,
236
+ xl: 12,
237
+ '2xl': 16,
238
+ '3xl': 28,
239
+ button: 9999,
240
+ buttonLg: 9999,
241
+ dialog: 28,
242
+ }
243
+
244
+ const roboto =
245
+ 'Roboto, "Roboto Flex", -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif'
246
+
247
+ /** Roboto on web (load it in your app); the platform font on iOS and Android. */
248
+ export const materialFonts: FontFamilies = { body: roboto, heading: roboto }
249
+
250
+ /**
251
+ * Options for `createUniversalConfig` that give every component the Material 3
252
+ * look: color roles from `seed`, Material's shapes, Roboto and, on Android,
253
+ * the press ripple.
254
+ *
255
+ * @example
256
+ * import { createUniversalConfig } from '@advui/theme'
257
+ * import { material } from '@advui/theme/material'
258
+ *
259
+ * export const config = createUniversalConfig(material({ seed: '#6750A4' }))
260
+ */
261
+ export function material(options: MaterialOptions = {}): UniversalConfigOptions {
262
+ return {
263
+ themes: createMaterialThemes(options),
264
+ radius: materialShape,
265
+ fonts: materialFonts,
266
+ androidRipple: options.androidRipple ?? true,
267
+ }
268
+ }
package/src/themes.ts CHANGED
@@ -65,6 +65,8 @@ export type ThemeValues = {
65
65
  primaryForeground: string
66
66
  /** Primary-colored text/links on the page background (≥4.5:1). `primary` is a fill color. */
67
67
  primaryText: string
68
+ /** Primary-colored text on inverse surfaces (`$foreground` as a background), e.g. a snackbar action. */
69
+ inversePrimary: string
68
70
  primarySoft: string
69
71
  primarySoftHover: string
70
72
  primarySoftForeground: string
@@ -240,6 +242,10 @@ function buildMode(mode: ColorMode, input: Required<Omit<ThemeColorsInput, 'over
240
242
  primaryPress: primary.press,
241
243
  primaryForeground: primary.foreground,
242
244
  primaryText: input.monochrome ? n[11] : ensureContrast(primaryScale[10], background),
245
+ // The other mode's text step reads on the inverted surface.
246
+ inversePrimary: input.monochrome
247
+ ? background
248
+ : ensureContrast(resolveScale(input.primary)[isDark ? 'light' : 'dark'][10], n[11]),
243
249
  primarySoft: primaryScale[2],
244
250
  primarySoftHover: primaryScale[3],
245
251
  primarySoftForeground: ensureContrast(primaryScale[10], primaryScale[3]),
package/src/tokens.ts CHANGED
@@ -71,15 +71,28 @@ export const radiusScales = {
71
71
 
72
72
  export type RadiusScale = keyof typeof radiusScales
73
73
 
74
+ export type RadiusToken =
75
+ 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'button' | 'buttonLg' | 'dialog'
76
+
77
+ /** Exact values for some radius tokens, on top of a base (`md` if omitted). */
78
+ export type RadiusOverrides = { base?: RadiusScale | number } & Partial<Record<RadiusToken, number>>
79
+
80
+ export type RadiusInput = RadiusScale | number | RadiusOverrides
81
+
74
82
  /**
75
83
  * Radius tokens derived from a single base value so the whole library can be
76
84
  * made sharper or rounder at once. Components pick semantic steps:
77
- * controls `$md`, menus/popovers `$lg`, cards/dialogs `$xl`, pills `$full`.
85
+ * controls `$md`, menus/popovers `$lg`, cards `$xl`, pills `$full`, and role
86
+ * tokens a design system can reshape on their own: `$button` / `$buttonLg`
87
+ * (buttons, icon buttons, toggles) and `$dialog` (dialogs and sheets).
78
88
  */
79
- export function createRadius(scale: RadiusScale | number = 'md') {
89
+ export function createRadius(input: RadiusInput = 'md') {
90
+ const overrides = typeof input === 'object' ? input : {}
91
+ const scale = typeof input === 'object' ? (input.base ?? 'md') : input
80
92
  const base = typeof scale === 'number' ? scale : radiusScales[scale]
81
93
  const r = (factor: number) => Math.round(base * factor)
82
- return {
94
+ const { base: _base, ...exact } = overrides
95
+ const tokens = {
83
96
  0: 0,
84
97
  xs: r(0.25),
85
98
  sm: r(0.5),
@@ -89,8 +102,12 @@ export function createRadius(scale: RadiusScale | number = 'md') {
89
102
  '2xl': r(2),
90
103
  '3xl': r(3),
91
104
  full: 9999,
92
- true: r(0.75),
105
+ button: r(0.75),
106
+ buttonLg: r(1),
107
+ dialog: r(1.5),
108
+ ...exact,
93
109
  }
110
+ return { ...tokens, true: tokens.md }
94
111
  }
95
112
 
96
113
  export const zIndex = {
@@ -112,7 +129,7 @@ export const colorTokens = {
112
129
  transparent: 'rgba(0, 0, 0, 0)',
113
130
  } as const
114
131
 
115
- export function createUniversalTokens({ radius = 'md' }: { radius?: RadiusScale | number } = {}) {
132
+ export function createUniversalTokens({ radius = 'md' }: { radius?: RadiusInput } = {}) {
116
133
  return createTokens({
117
134
  space,
118
135
  size,