@ponti-studios/ui 1.7.0 → 2.0.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 (51) hide show
  1. package/README.md +54 -20
  2. package/dist/styles.css +3 -0
  3. package/package.json +28 -9
  4. package/scripts/create-project.mjs +82 -0
  5. package/src/components/forms/checkbox.stories.tsx +35 -0
  6. package/src/components/forms/checkbox.tsx +38 -0
  7. package/src/components/forms/index.ts +2 -0
  8. package/src/components/forms/label.stories.tsx +16 -0
  9. package/src/components/forms/label.tsx +25 -0
  10. package/src/components/forms/select.tsx +3 -1
  11. package/src/components/overlays/bottom-sheet.stories.tsx +52 -0
  12. package/src/components/overlays/bottom-sheet.tsx +179 -0
  13. package/src/components/overlays/command.tsx +1 -1
  14. package/src/components/overlays/dropdown-menu.tsx +6 -2
  15. package/src/components/overlays/index.ts +2 -0
  16. package/src/components/overlays/popover.tsx +12 -3
  17. package/src/components/overlays/tooltip.stories.tsx +29 -0
  18. package/src/components/overlays/tooltip.tsx +41 -0
  19. package/src/components/primitives/index.ts +2 -0
  20. package/src/components/primitives/segmented-control.stories.tsx +54 -0
  21. package/src/components/primitives/segmented-control.tsx +73 -0
  22. package/src/components/primitives/separator.stories.tsx +34 -0
  23. package/src/components/primitives/separator.tsx +27 -0
  24. package/src/index.ts +2 -0
  25. package/src/native/components.tsx +198 -0
  26. package/src/native/index.ts +2 -0
  27. package/src/native/theme.tsx +69 -0
  28. package/src/styles/build.ts +1 -0
  29. package/src/styles/index.css +102 -8
  30. package/src/styles/package.css +3 -0
  31. package/src/styles/tokens/color-systems.stories.tsx +173 -25
  32. package/src/styles/tokens/generated/dark-class.css +39 -0
  33. package/src/styles/tokens/generated/dark.colors.generated.ts +31 -26
  34. package/src/styles/tokens/generated/dark.css +31 -26
  35. package/src/styles/tokens/generated/foundations.css +2 -2
  36. package/src/styles/tokens/generated/foundations.generated.ts +2 -0
  37. package/src/styles/tokens/generated/light.colors.generated.ts +30 -25
  38. package/src/styles/tokens/generated/light.css +30 -25
  39. package/src/styles/tokens/index.ts +6 -6
  40. package/src/styles/tokens/scripts/check-source.mjs +10 -0
  41. package/src/styles/tokens/source/dark.tokens.json +48 -27
  42. package/src/styles/tokens/source/foundations.tokens.json +2 -0
  43. package/src/styles/tokens/source/light.tokens.json +47 -26
  44. package/src/styles/tokens/style-dictionary.dark-class.json +16 -0
  45. package/templates/web-vite/index.html +13 -0
  46. package/templates/web-vite/package.json +27 -0
  47. package/templates/web-vite/src/App.tsx +31 -0
  48. package/templates/web-vite/src/main.tsx +12 -0
  49. package/templates/web-vite/src/styles.css +110 -0
  50. package/templates/web-vite/tsconfig.json +21 -0
  51. package/templates/web-vite/vite.config.ts +6 -0
@@ -0,0 +1,198 @@
1
+ import React, { type ReactNode } from "react";
2
+ import { Pressable, Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
3
+ import { useNativeTheme } from "./theme";
4
+
5
+ export type NativeButtonVariant = "primary" | "secondary" | "ghost" | "destructive";
6
+ export type NativeButtonSize = "small" | "medium" | "large";
7
+
8
+ export interface NativeButtonProps {
9
+ label: string;
10
+ onPress: PressableProps["onPress"];
11
+ variant?: NativeButtonVariant;
12
+ size?: NativeButtonSize;
13
+ disabled?: boolean;
14
+ loading?: boolean;
15
+ accessibilityLabel?: string;
16
+ accessibilityHint?: string;
17
+ leading?: ReactNode;
18
+ style?: StyleProp<ViewStyle>;
19
+ }
20
+
21
+ export function NativeButton({
22
+ label,
23
+ onPress,
24
+ variant = "primary",
25
+ size = "medium",
26
+ disabled = false,
27
+ loading = false,
28
+ accessibilityLabel,
29
+ accessibilityHint,
30
+ leading,
31
+ style,
32
+ }: NativeButtonProps) {
33
+ const { colors, spacing, textSizes, fontWeights, radii } = useNativeTheme();
34
+ const isDisabled = disabled || loading;
35
+ const [backgroundColor, foregroundColor] = isDisabled
36
+ ? [colors.muted, colors.tertiary]
37
+ : buttonColors(variant, colors);
38
+ const verticalPadding = size === "small" ? spacing["2"] : size === "large" ? spacing["4"] : spacing["3"];
39
+ const horizontalPadding = size === "small" ? spacing["3"] : spacing["4"];
40
+ const fontSize = size === "small" ? textSizes.sm : size === "large" ? textSizes.lg : textSizes.base;
41
+
42
+ return (
43
+ <Pressable
44
+ accessibilityRole="button"
45
+ accessibilityLabel={accessibilityLabel ?? label}
46
+ accessibilityHint={accessibilityHint}
47
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
48
+ disabled={isDisabled}
49
+ onPress={onPress}
50
+ style={[
51
+ {
52
+ minHeight: size === "large" ? spacing["14"] : spacing["12"],
53
+ paddingHorizontal: horizontalPadding,
54
+ paddingVertical: verticalPadding,
55
+ borderRadius: radii.lg,
56
+ backgroundColor,
57
+ alignItems: "center",
58
+ justifyContent: "center",
59
+ flexDirection: "row",
60
+ gap: spacing["2"],
61
+ },
62
+ style,
63
+ ]}
64
+ >
65
+ {leading}
66
+ <Text style={{ color: foregroundColor, fontSize, fontWeight: fontWeights.semibold }}>{label}</Text>
67
+ </Pressable>
68
+ );
69
+ }
70
+
71
+ function buttonColors(variant: NativeButtonVariant, colors: ReturnType<typeof useNativeTheme>["colors"]): [string, string] {
72
+ switch (variant) {
73
+ case "primary":
74
+ return [colors.primary, colors["primary-foreground"]];
75
+ case "destructive":
76
+ return [colors.destructive, colors["destructive-foreground"]];
77
+ case "secondary":
78
+ return [colors.secondary, colors["text-primary"]];
79
+ case "ghost":
80
+ return ["transparent", colors["text-primary"]];
81
+ }
82
+ }
83
+
84
+ export interface NativeSurfaceProps {
85
+ children: ReactNode;
86
+ tone?: "card" | "muted" | "background";
87
+ style?: StyleProp<ViewStyle>;
88
+ }
89
+
90
+ export function NativeSurface({ children, tone = "card", style }: NativeSurfaceProps) {
91
+ const { colors, spacing, radii } = useNativeTheme();
92
+ const backgroundColor = tone === "muted" ? colors.muted : tone === "background" ? colors.background : colors.card;
93
+ return (
94
+ <View
95
+ style={[
96
+ {
97
+ backgroundColor,
98
+ borderColor: colors["border-default"],
99
+ borderWidth: spacing.px,
100
+ borderRadius: radii.xl,
101
+ padding: spacing["4"],
102
+ gap: spacing["2"],
103
+ },
104
+ style,
105
+ ]}
106
+ >
107
+ {children}
108
+ </View>
109
+ );
110
+ }
111
+
112
+ export type NativeBadgeVariant = "default" | "secondary" | "accent" | "success" | "warning" | "destructive";
113
+
114
+ export interface NativeBadgeProps {
115
+ label: string;
116
+ variant?: NativeBadgeVariant;
117
+ accessibilityLabel?: string;
118
+ }
119
+
120
+ export function NativeBadge({ label, variant = "default", accessibilityLabel }: NativeBadgeProps) {
121
+ const { colors, spacing, textSizes, fontWeights, radii } = useNativeTheme();
122
+ const [backgroundColor, foregroundColor] = badgeColors(variant, colors);
123
+ return (
124
+ <View
125
+ accessibilityRole="text"
126
+ accessibilityLabel={accessibilityLabel ?? label}
127
+ style={{ alignSelf: "flex-start", paddingHorizontal: spacing["2"], paddingVertical: spacing["1"], borderRadius: radii.full, backgroundColor }}
128
+ >
129
+ <Text style={{ color: foregroundColor, fontSize: textSizes.sm, fontWeight: fontWeights.medium }}>{label}</Text>
130
+ </View>
131
+ );
132
+ }
133
+
134
+ function badgeColors(variant: NativeBadgeVariant, colors: ReturnType<typeof useNativeTheme>["colors"]): [string, string] {
135
+ switch (variant) {
136
+ case "secondary":
137
+ return [colors.secondary, colors["text-primary"]];
138
+ case "accent":
139
+ return [colors.accent, colors["text-primary"]];
140
+ case "success":
141
+ return [colors.success, colors["success-foreground"]];
142
+ case "warning":
143
+ return [colors.warning, colors["warning-foreground"]];
144
+ case "destructive":
145
+ return [colors.destructive, colors["destructive-foreground"]];
146
+ case "default":
147
+ return [colors.primary, colors["primary-foreground"]];
148
+ }
149
+ }
150
+
151
+ export interface NativeTabItem<Value extends string = string> {
152
+ value: Value;
153
+ label: string;
154
+ accessibilityLabel?: string;
155
+ }
156
+
157
+ export interface NativeTabBarProps<Value extends string> {
158
+ items: readonly NativeTabItem<Value>[];
159
+ value: Value;
160
+ onChange: (value: Value) => void;
161
+ }
162
+
163
+ export function NativeTabBar<Value extends string>({ items, value, onChange }: NativeTabBarProps<Value>) {
164
+ const { colors, spacing, textSizes, fontWeights, radii } = useNativeTheme();
165
+ return (
166
+ <View
167
+ accessibilityRole="tablist"
168
+ style={{ flexDirection: "row", backgroundColor: colors.card, borderTopColor: colors["border-default"], borderTopWidth: spacing.px, padding: spacing["2"], gap: spacing["1"] }}
169
+ >
170
+ {items.map((item) => {
171
+ const selected = item.value === value;
172
+ return (
173
+ <Pressable
174
+ key={item.value}
175
+ accessibilityRole="tab"
176
+ accessibilityLabel={item.accessibilityLabel ?? item.label}
177
+ accessibilityState={{ selected }}
178
+ onPress={() => onChange(item.value)}
179
+ style={({ pressed }) => ({
180
+ flex: 1,
181
+ minHeight: spacing["12"],
182
+ paddingHorizontal: spacing["2"],
183
+ paddingVertical: spacing["2"],
184
+ borderRadius: radii.lg,
185
+ backgroundColor: selected ? colors.accent : pressed ? colors.secondary : colors.card,
186
+ alignItems: "center",
187
+ justifyContent: "center",
188
+ })}
189
+ >
190
+ <Text style={{ color: selected ? colors["text-primary"] : colors["text-secondary"], fontSize: textSizes.sm, fontWeight: selected ? fontWeights.semibold : fontWeights.normal }}>
191
+ {item.label}
192
+ </Text>
193
+ </Pressable>
194
+ );
195
+ })}
196
+ </View>
197
+ );
198
+ }
@@ -0,0 +1,2 @@
1
+ export { NativeBadge, NativeButton, NativeSurface, NativeTabBar, type NativeBadgeProps, type NativeBadgeVariant, type NativeButtonProps, type NativeButtonSize, type NativeButtonVariant, type NativeSurfaceProps, type NativeTabBarProps, type NativeTabItem } from "./components";
2
+ export { NativeThemeProvider, useNativeTheme, type NativeColorPreference, type NativeTheme, type NativeThemeProviderProps } from "./theme";
@@ -0,0 +1,69 @@
1
+ import React, { createContext, useContext, useMemo } from "react";
2
+ import { useColorScheme, type TextStyle } from "react-native";
3
+ import { colorThemes, fontWeights, radii, spacing, textLineHeights, textSizes, type ColorMode, type ColorTheme } from "../styles/tokens";
4
+
5
+ export type NativeColorPreference = ColorMode | "system";
6
+
7
+ export interface NativeTheme {
8
+ mode: ColorMode;
9
+ colors: ColorTheme;
10
+ spacing: typeof spacing;
11
+ textSizes: typeof textSizes;
12
+ textLineHeights: typeof textLineHeights;
13
+ fontWeights: Readonly<Record<keyof typeof fontWeights, NonNullable<TextStyle["fontWeight"]>>>;
14
+ radii: typeof radii;
15
+ }
16
+
17
+ function nativeFontWeight(token: keyof typeof fontWeights): NonNullable<TextStyle["fontWeight"]> {
18
+ switch (fontWeights[token]) {
19
+ case "100": return "100";
20
+ case "200": return "200";
21
+ case "300": return "300";
22
+ case "400": return "400";
23
+ case "500": return "500";
24
+ case "600": return "600";
25
+ case "700": return "700";
26
+ case "800": return "800";
27
+ case "900": return "900";
28
+ default: throw new Error(`Unsupported native font-weight token: ${fontWeights[token]}`);
29
+ }
30
+ }
31
+
32
+ const nativeFontWeights: NativeTheme["fontWeights"] = {
33
+ thin: nativeFontWeight("thin"),
34
+ extralight: nativeFontWeight("extralight"),
35
+ light: nativeFontWeight("light"),
36
+ normal: nativeFontWeight("normal"),
37
+ medium: nativeFontWeight("medium"),
38
+ semibold: nativeFontWeight("semibold"),
39
+ bold: nativeFontWeight("bold"),
40
+ extrabold: nativeFontWeight("extrabold"),
41
+ black: nativeFontWeight("black"),
42
+ };
43
+
44
+ const NativeThemeContext = createContext<NativeTheme>({
45
+ mode: "light",
46
+ colors: colorThemes.light,
47
+ spacing,
48
+ textSizes,
49
+ textLineHeights,
50
+ fontWeights: nativeFontWeights,
51
+ radii,
52
+ });
53
+
54
+ export interface NativeThemeProviderProps {
55
+ children: React.ReactNode;
56
+ colorPreference?: NativeColorPreference;
57
+ }
58
+
59
+ export function NativeThemeProvider({ children, colorPreference = "system" }: NativeThemeProviderProps) {
60
+ const systemMode = useColorScheme();
61
+ const mode: ColorMode = colorPreference === "system" ? (systemMode === "dark" ? "dark" : "light") : colorPreference;
62
+ const value = useMemo<NativeTheme>(() => ({ mode, colors: colorThemes[mode], spacing, textSizes, textLineHeights, fontWeights: nativeFontWeights, radii }), [mode]);
63
+
64
+ return <NativeThemeContext.Provider value={value}>{children}</NativeThemeContext.Provider>;
65
+ }
66
+
67
+ export function useNativeTheme(): NativeTheme {
68
+ return useContext(NativeThemeContext);
69
+ }
@@ -0,0 +1 @@
1
+ import "./package.css";
@@ -17,6 +17,7 @@
17
17
  @import "./tokens/generated/foundations.css";
18
18
  @import "./tokens/generated/light.css";
19
19
  @import "./tokens/generated/dark.css" (prefers-color-scheme: dark);
20
+ @import "./tokens/generated/dark-class.css";
20
21
  @import "./animations.css";
21
22
  @import "./nokia.css";
22
23
 
@@ -54,20 +55,18 @@
54
55
  --color-foreground: var(--text-primary);
55
56
  --color-card: var(--card);
56
57
  --color-card-foreground: var(--text-primary);
57
- --color-popover: var(--background);
58
+ --color-popover: var(--popover);
58
59
  --color-popover-foreground: var(--text-primary);
59
60
  --color-primary: var(--primary);
60
61
  --color-primary-foreground: var(--primary-foreground);
61
- --color-secondary: var(--card);
62
+ --color-secondary: var(--secondary);
62
63
  --color-secondary-foreground: var(--text-primary);
63
64
  --color-muted: var(--muted);
64
65
  --color-muted-foreground: var(--text-secondary);
65
- --color-accent: var(--primary);
66
- --color-accent-foreground: var(--primary-foreground);
67
- /* Distinct from --color-accent: that pairs with white/primary-foreground
68
- * for solid fills (badges, active states). This pairs with the page
69
- * background for standalone text/icons/borders (inline links, upload
70
- * affordances) and must differ per theme to stay AA-compliant. */
66
+ --color-accent: var(--accent);
67
+ --color-accent-foreground: var(--accent-foreground);
68
+ /* Distinct from --color-accent: this pairs with the page background for
69
+ * standalone text/icons/borders (inline links, upload affordances). */
71
70
  --color-accent-text: var(--text-accent);
72
71
  --color-destructive: var(--destructive);
73
72
  --color-destructive-foreground: var(--destructive-foreground);
@@ -78,8 +77,10 @@
78
77
  --color-destructive-text: var(--text-destructive);
79
78
  --color-success: var(--success);
80
79
  --color-success-foreground: var(--success-foreground);
80
+ --color-success-text: var(--text-success);
81
81
  --color-warning: var(--warning);
82
82
  --color-warning-foreground: var(--warning-foreground);
83
+ --color-warning-text: var(--text-warning);
83
84
  --color-border: var(--border-default);
84
85
  --color-input: var(--border-default);
85
86
  --color-ring: var(--focus-ring);
@@ -200,6 +201,85 @@
200
201
  height: 100dvh;
201
202
  }
202
203
 
204
+ /* Responsive dropdown surfaces keep their menu/listbox semantics while
205
+ * becoming thumb-reachable sheets on compact viewports. */
206
+ .ui-mobile-sheet-backdrop {
207
+ display: none;
208
+ }
209
+ .ui-mobile-sheet-positioner {
210
+ z-index: var(--z-index-50);
211
+ }
212
+ .ui-mobile-sheet-close {
213
+ display: none;
214
+ }
215
+ @media (max-width: 767px) {
216
+ .ui-mobile-sheet-backdrop {
217
+ position: fixed;
218
+ z-index: var(--z-index-50);
219
+ display: block;
220
+ background: color-mix(in srgb, var(--overlay-scrim) 50%, transparent);
221
+ inset: 0;
222
+ }
223
+ .ui-mobile-sheet-positioner {
224
+ position: fixed !important;
225
+ z-index: calc(var(--z-index-50) + 1) !important;
226
+ display: block;
227
+ width: 100% !important;
228
+ height: auto !important;
229
+ max-height: none !important;
230
+ transform: none !important;
231
+ inset: auto 0 0 !important;
232
+ }
233
+ .ui-mobile-sheet-popup {
234
+ width: 100% !important;
235
+ min-width: 0 !important;
236
+ max-width: none !important;
237
+ max-height: min(75dvh, var(--available-height, 75dvh)) !important;
238
+ border-start-start-radius: var(--radius-2xl) !important;
239
+ border-start-end-radius: var(--radius-2xl) !important;
240
+ border-end-start-radius: 0 !important;
241
+ border-end-end-radius: 0 !important;
242
+ padding-bottom: max(var(--spacing-scale-4), env(safe-area-inset-bottom)) !important;
243
+ overscroll-behavior: contain;
244
+ }
245
+ .ui-mobile-popover-popup {
246
+ padding-block-start: calc(var(--spacing-scale-12) + var(--spacing-scale-2)) !important;
247
+ }
248
+ .ui-mobile-sheet-close {
249
+ position: absolute;
250
+ z-index: var(--z-index-10);
251
+ top: var(--spacing-scale-2);
252
+ right: var(--spacing-scale-3);
253
+ display: grid;
254
+ width: var(--spacing-scale-12);
255
+ height: var(--spacing-scale-12);
256
+ place-items: center;
257
+ border: 1px solid var(--border-default);
258
+ border-radius: var(--radius-full);
259
+ color: var(--text-secondary);
260
+ background: var(--background);
261
+ cursor: pointer;
262
+ }
263
+ .ui-mobile-sheet-close svg {
264
+ width: var(--spacing-scale-5);
265
+ height: var(--spacing-scale-5);
266
+ }
267
+ [data-slot="dropdown-menu-content"] [data-slot^="dropdown-menu-item"],
268
+ [data-slot="select-content"] [data-slot="select-item"] {
269
+ min-height: var(--spacing-scale-12);
270
+ }
271
+ [data-slot="dropdown-menu-content"][data-open],
272
+ [data-slot="select-content"][data-open],
273
+ [data-slot="popover-content"][data-open] {
274
+ animation: sheet-slide-in-bottom 300ms var(--ease-drawer);
275
+ }
276
+ [data-slot="dropdown-menu-content"][data-closed],
277
+ [data-slot="select-content"][data-closed],
278
+ [data-slot="popover-content"][data-closed] {
279
+ animation: sheet-slide-out-bottom 200ms var(--ease-drawer);
280
+ }
281
+ }
282
+
203
283
  /* Shared layout rhythm. Product components own their layout; this does not
204
284
  * define visual component families. */
205
285
  .page-shell {
@@ -264,6 +344,16 @@
264
344
  @apply text-muted-foreground text-xs font-medium uppercase tracking-widest;
265
345
  }
266
346
 
347
+ /* Command rows: action hints stay silent until the row is highlighted,
348
+ * so the list scans clean and the affordance appears exactly when usable. */
349
+ [data-slot="command-item"] [data-slot="command-shortcut"] {
350
+ opacity: 0;
351
+ transition: opacity 120ms ease-out;
352
+ }
353
+ [data-slot="command-item"][data-highlighted] [data-slot="command-shortcut"] {
354
+ opacity: 1;
355
+ }
356
+
267
357
  @layer base {
268
358
  :root {
269
359
  color-scheme: light;
@@ -273,6 +363,10 @@
273
363
  color-scheme: dark;
274
364
  }
275
365
  }
366
+ /* Manual override: toggling `.dark` on <html> beats the OS preference. */
367
+ :root.dark {
368
+ color-scheme: dark;
369
+ }
276
370
  html,
277
371
  body {
278
372
  background-color: var(--color-background);
@@ -0,0 +1,3 @@
1
+ @import "tailwindcss" source(none);
2
+ @import "./index.css";
3
+ @source "../components";
@@ -2,40 +2,188 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
 
3
3
  import { Button } from "../../components/primitives/button";
4
4
  import { colors } from "./css-vars";
5
- import { colorTokenNames } from "./index";
5
+ import type { ColorToken } from "./index";
6
6
 
7
7
  const meta: Meta = { title: "Foundations/Colors", parameters: { layout: "fullscreen" } };
8
8
  export default meta;
9
9
  type Story = StoryObj<typeof meta>;
10
10
 
11
+ type Role = {
12
+ token: ColorToken;
13
+ purpose: string;
14
+ foreground?: ColorToken;
15
+ };
16
+
17
+ const surfaces: Role[] = [
18
+ { token: "background", purpose: "The page and app shell surface." },
19
+ { token: "card", purpose: "Content containers and grouped information." },
20
+ { token: "popover", purpose: "Dropdowns, dialogs, and floating panels." },
21
+ { token: "muted", purpose: "Quiet, disabled, and loading surfaces." },
22
+ { token: "secondary", purpose: "Secondary actions and selectable chips." },
23
+ {
24
+ token: "accent",
25
+ purpose: "Tinted emphasis and hover surfaces.",
26
+ foreground: "accent-foreground",
27
+ },
28
+ ];
29
+
30
+ const text: Role[] = [
31
+ { token: "text-primary", purpose: "Primary reading text." },
32
+ { token: "text-secondary", purpose: "Supporting labels and descriptions." },
33
+ { token: "tertiary", purpose: "Metadata, timestamps, and placeholders." },
34
+ { token: "text-accent", purpose: "Standalone links and accent affordances." },
35
+ { token: "text-success", purpose: "Standalone positive status text and icons." },
36
+ { token: "text-warning", purpose: "Standalone cautionary status text and icons." },
37
+ { token: "text-destructive", purpose: "Standalone error and destructive text." },
38
+ ];
39
+
40
+ const actions: Role[] = [
41
+ {
42
+ token: "primary",
43
+ purpose: "The strongest action on a screen.",
44
+ foreground: "primary-foreground",
45
+ },
46
+ {
47
+ token: "destructive",
48
+ purpose: "Irreversible or dangerous actions.",
49
+ foreground: "destructive-foreground",
50
+ },
51
+ {
52
+ token: "success",
53
+ purpose: "Positive indicators and confirmed states.",
54
+ foreground: "success-foreground",
55
+ },
56
+ {
57
+ token: "warning",
58
+ purpose: "Caution indicators and attention states.",
59
+ foreground: "warning-foreground",
60
+ },
61
+ ];
62
+
63
+ const system: Role[] = [
64
+ { token: "border-default", purpose: "Default borders, inputs, and dividers." },
65
+ { token: "focus-ring", purpose: "Keyboard focus indication." },
66
+ { token: "overlay-scrim", purpose: "Backdrop behind modal surfaces." },
67
+ ];
68
+
69
+ const charts: Role[] = [
70
+ { token: "chart-1", purpose: "Categorical data series one." },
71
+ { token: "chart-2", purpose: "Categorical data series two." },
72
+ { token: "chart-3", purpose: "Categorical data series three." },
73
+ { token: "chart-4", purpose: "Categorical data series four." },
74
+ { token: "chart-5", purpose: "Categorical data series five." },
75
+ { token: "chart-positive", purpose: "Positive trend in a visualization." },
76
+ { token: "chart-negative", purpose: "Negative trend in a visualization." },
77
+ { token: "chart-neutral", purpose: "Neutral or baseline trend." },
78
+ ];
79
+
80
+ function RoleCard({ token, purpose, foreground }: Role) {
81
+ const isText = token.startsWith("text-") || token === "tertiary";
82
+ const isSurface = surfaces.some((role) => role.token === token);
83
+ const swatchOnly = !isText && !isSurface && !foreground;
84
+ const background = isText ? "background" : token;
85
+ const contentColor = isText ? token : (foreground ?? "text-primary");
86
+
87
+ return (
88
+ <article className="border-border overflow-hidden rounded-md border bg-card">
89
+ <div
90
+ className="flex min-h-24 items-end p-3"
91
+ style={{
92
+ backgroundColor: colors[swatchOnly ? "card" : background],
93
+ color: colors[swatchOnly ? "text-primary" : contentColor],
94
+ }}
95
+ >
96
+ {swatchOnly ? (
97
+ <span
98
+ aria-hidden="true"
99
+ className="h-full min-h-16 w-full rounded-sm"
100
+ style={{ backgroundColor: colors[token] }}
101
+ />
102
+ ) : (
103
+ <span className="font-mono text-xs">{token}</span>
104
+ )}
105
+ </div>
106
+ <div className="grid gap-1 p-3">
107
+ <code className="text-sm font-medium">--{token}</code>
108
+ <p className="text-muted-foreground text-sm">{purpose}</p>
109
+ {foreground && (
110
+ <p className="text-muted-foreground text-xs">Paired content: --{foreground}</p>
111
+ )}
112
+ </div>
113
+ </article>
114
+ );
115
+ }
116
+
117
+ function RoleSection({
118
+ title,
119
+ description,
120
+ roles,
121
+ }: {
122
+ title: string;
123
+ description: string;
124
+ roles: Role[];
125
+ }) {
126
+ return (
127
+ <section className="grid gap-3">
128
+ <div className="grid gap-1">
129
+ <h2 className="text-lg font-semibold">{title}</h2>
130
+ <p className="text-muted-foreground text-sm">{description}</p>
131
+ </div>
132
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
133
+ {roles.map((role) => (
134
+ <RoleCard key={role.token} {...role} />
135
+ ))}
136
+ </div>
137
+ </section>
138
+ );
139
+ }
140
+
11
141
  export const SystemAppearance: Story = {
12
142
  render: () => (
13
- <main className="bg-background text-foreground grid min-h-screen gap-8 p-6 md:p-10">
14
- <header className="grid gap-2">
15
- <h1 className="text-2xl font-semibold">System color roles</h1>
16
- <p className="text-muted-foreground max-w-prose text-sm">
17
- These semantic roles resolve from the operating system’s light or dark appearance. CSS is
18
- the single source of their values.
143
+ <main className="bg-background text-foreground grid min-h-screen gap-10 p-6 md:p-10">
144
+ <header className="grid max-w-3xl gap-2">
145
+ <p className="text-accent-text text-xs font-medium uppercase tracking-widest">
146
+ Foundations
19
147
  </p>
148
+ <h1 className="text-2xl font-semibold">Color roles</h1>
149
+ <p className="text-muted-foreground text-sm">
150
+ Every color has a job. These roles resolve from the operating system’s light or dark
151
+ appearance and are the only colors components should consume.
152
+ </p>
153
+ <div className="flex flex-wrap gap-2 pt-2">
154
+ <Button>Primary action</Button>
155
+ <Button variant="secondary">Secondary action</Button>
156
+ <Button variant="outline">Outline action</Button>
157
+ <Button variant="destructive">Delete item</Button>
158
+ </div>
20
159
  </header>
21
- <div className="flex flex-wrap gap-2">
22
- <Button>Primary action</Button>
23
- <Button variant="secondary">Secondary action</Button>
24
- <Button variant="outline">Outline action</Button>
25
- <Button variant="destructive">Delete item</Button>
26
- </div>
27
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
28
- {colorTokenNames.map((token) => (
29
- <div
30
- key={token}
31
- className="border-border grid min-h-20 content-end rounded-md border p-3"
32
- style={{ backgroundColor: colors[token] }}
33
- >
34
- <span className="bg-background text-foreground w-fit rounded px-1 font-mono text-xs">
35
- {token}
36
- </span>
37
- </div>
38
- ))}
160
+
161
+ <div className="grid gap-10">
162
+ <RoleSection
163
+ title="Surfaces"
164
+ description="Backgrounds establish hierarchy. Use the lightest appropriate surface and let spacing do the rest."
165
+ roles={surfaces}
166
+ />
167
+ <RoleSection
168
+ title="Text"
169
+ description="Text roles describe hierarchy or state. Use a text role when content sits directly on a surface."
170
+ roles={text}
171
+ />
172
+ <RoleSection
173
+ title="Actions and status"
174
+ description="Fill roles communicate action or state and always pair with their matching foreground."
175
+ roles={actions}
176
+ />
177
+ <RoleSection
178
+ title="System"
179
+ description="Structural colors support boundaries, focus, and modal layering without competing with content."
180
+ roles={system}
181
+ />
182
+ <RoleSection
183
+ title="Charts"
184
+ description="Chart roles are for data visualization only. They do not communicate interaction or severity in product UI."
185
+ roles={charts}
186
+ />
39
187
  </div>
40
188
  </main>
41
189
  ),