@nationaldesignstudio/react 0.2.0 → 0.5.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 (97) hide show
  1. package/dist/component-registry.md +1310 -127
  2. package/dist/components/atoms/background/background.d.ts +13 -27
  3. package/dist/components/atoms/button/button.d.ts +64 -72
  4. package/dist/components/atoms/button/button.figma.d.ts +1 -0
  5. package/dist/components/atoms/button/icon-button.d.ts +62 -110
  6. package/dist/components/atoms/input/input-group.d.ts +278 -0
  7. package/dist/components/atoms/input/input.d.ts +121 -0
  8. package/dist/components/atoms/popover/popover.d.ts +195 -0
  9. package/dist/components/atoms/select/select.d.ts +131 -0
  10. package/dist/components/atoms/tooltip/tooltip.d.ts +161 -0
  11. package/dist/components/organisms/card/card.d.ts +3 -3
  12. package/dist/components/sections/hero/hero.d.ts +2 -2
  13. package/dist/components/sections/prose/prose.d.ts +3 -3
  14. package/dist/components/sections/river/river.d.ts +1 -1
  15. package/dist/components/sections/tout/tout.d.ts +4 -4
  16. package/dist/components/shared/floating-arrow.d.ts +34 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.js +13935 -7622
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/form-control.d.ts +106 -0
  21. package/dist/tokens.css +4725 -19065
  22. package/package.json +2 -1
  23. package/src/components/atoms/accordion/accordion.stories.tsx +1 -1
  24. package/src/components/atoms/accordion/accordion.tsx +2 -2
  25. package/src/components/atoms/background/background.tsx +71 -109
  26. package/src/components/atoms/button/button.figma.tsx +37 -0
  27. package/src/components/atoms/button/button.stories.tsx +253 -115
  28. package/src/components/atoms/button/button.test.tsx +289 -5
  29. package/src/components/atoms/button/button.tsx +40 -101
  30. package/src/components/atoms/button/button.visual.test.tsx +28 -32
  31. package/src/components/atoms/button/icon-button.stories.tsx +44 -101
  32. package/src/components/atoms/button/icon-button.test.tsx +26 -94
  33. package/src/components/atoms/button/icon-button.tsx +81 -224
  34. package/src/components/atoms/input/index.ts +17 -0
  35. package/src/components/atoms/input/input-group.stories.tsx +646 -0
  36. package/src/components/atoms/input/input-group.test.tsx +362 -0
  37. package/src/components/atoms/input/input-group.tsx +409 -0
  38. package/src/components/atoms/input/input.stories.tsx +228 -0
  39. package/src/components/atoms/input/input.test.tsx +167 -0
  40. package/src/components/atoms/input/input.tsx +104 -0
  41. package/src/components/atoms/pager-control/pager-control.stories.tsx +6 -8
  42. package/src/components/atoms/pager-control/pager-control.tsx +12 -12
  43. package/src/components/atoms/popover/index.ts +30 -0
  44. package/src/components/atoms/popover/popover.stories.tsx +531 -0
  45. package/src/components/atoms/popover/popover.test.tsx +486 -0
  46. package/src/components/atoms/popover/popover.tsx +488 -0
  47. package/src/components/atoms/select/index.ts +18 -0
  48. package/src/components/atoms/select/select.stories.tsx +455 -0
  49. package/src/components/atoms/select/select.tsx +324 -0
  50. package/src/components/atoms/tooltip/index.ts +24 -0
  51. package/src/components/atoms/tooltip/tooltip.stories.tsx +348 -0
  52. package/src/components/atoms/tooltip/tooltip.test.tsx +363 -0
  53. package/src/components/atoms/tooltip/tooltip.tsx +347 -0
  54. package/src/components/dev-tools/dev-toolbar/dev-toolbar.stories.tsx +8 -17
  55. package/src/components/dev-tools/dev-toolbar/dev-toolbar.tsx +3 -3
  56. package/src/components/foundation/typography/typography.stories.tsx +401 -0
  57. package/src/components/organisms/card/card.stories.tsx +19 -19
  58. package/src/components/organisms/card/card.test.tsx +1 -1
  59. package/src/components/organisms/card/card.tsx +3 -3
  60. package/src/components/organisms/card/card.visual.test.tsx +11 -11
  61. package/src/components/organisms/navbar/navbar.tsx +2 -2
  62. package/src/components/organisms/navbar/navbar.visual.test.tsx +2 -2
  63. package/src/components/organisms/us-gov-banner/us-gov-banner.tsx +2 -2
  64. package/src/components/sections/banner/banner.stories.tsx +1 -5
  65. package/src/components/sections/banner/banner.test.tsx +2 -2
  66. package/src/components/sections/banner/banner.tsx +6 -6
  67. package/src/components/sections/card-grid/card-grid.tsx +5 -5
  68. package/src/components/sections/faq-section/faq-section.tsx +2 -2
  69. package/src/components/sections/hero/hero.stories.tsx +7 -7
  70. package/src/components/sections/hero/hero.test.tsx +5 -5
  71. package/src/components/sections/hero/hero.tsx +10 -11
  72. package/src/components/sections/prose/prose.test.tsx +2 -2
  73. package/src/components/sections/prose/prose.tsx +6 -7
  74. package/src/components/sections/river/river.stories.tsx +8 -8
  75. package/src/components/sections/river/river.test.tsx +4 -4
  76. package/src/components/sections/river/river.tsx +8 -16
  77. package/src/components/sections/tout/tout.stories.tsx +7 -31
  78. package/src/components/sections/tout/tout.test.tsx +1 -1
  79. package/src/components/sections/tout/tout.tsx +11 -11
  80. package/src/components/sections/two-column-section/two-column-section.tsx +7 -9
  81. package/src/components/shared/floating-arrow.tsx +78 -0
  82. package/src/components/shared/index.ts +5 -0
  83. package/src/index.ts +98 -0
  84. package/src/lib/form-control.ts +71 -0
  85. package/src/stories/grid-system.stories.tsx +309 -0
  86. package/src/stories/{Introduction.mdx → introduction.mdx} +29 -15
  87. package/src/stories/{ThemeProvider.stories.tsx → theme-provider.stories.tsx} +8 -22
  88. package/src/stories/{TokenShowcase.stories.tsx → token-showcase.stories.tsx} +1 -20
  89. package/src/stories/token-showcase.tsx +777 -0
  90. package/src/styles.css +3 -0
  91. package/src/tests/token-resolution.test.tsx +298 -0
  92. package/src/theme/hooks.ts +1 -1
  93. package/src/theme/index.ts +1 -1
  94. package/src/theme/theme-provider.test.tsx +270 -0
  95. package/src/theme/{ThemeProvider.tsx → theme-provider.tsx} +18 -2
  96. package/src/stories/GridSystem.stories.tsx +0 -84
  97. package/src/stories/TokenShowcase.tsx +0 -1429
package/src/styles.css CHANGED
@@ -1,3 +1,6 @@
1
+ /* Google Fonts - must be first to comply with CSS @import ordering rules */
2
+ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Inter+Tight:wght@100..900&display=swap");
3
+
1
4
  @import "tailwindcss";
2
5
  @import "../../tailwind-token-generator/dist/tokens.css";
3
6
 
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Token Resolution Tests
3
+ *
4
+ * These tests verify that CSS classes using design tokens resolve to the correct
5
+ * computed styles. This ensures that:
6
+ * 1. Semantic color tokens resolve to actual colors
7
+ * 2. Typography tokens apply correct font properties
8
+ * 3. Spacing tokens resolve to correct pixel values
9
+ */
10
+
11
+ import { describe, expect, test } from "vitest";
12
+ import { page } from "vitest/browser";
13
+ import { render } from "vitest-browser-react";
14
+ import { Button } from "../components/atoms/button/button";
15
+
16
+ /**
17
+ * Helper to get computed style of an element
18
+ */
19
+ function getComputedStyleValue(element: Element, property: string): string {
20
+ return window.getComputedStyle(element).getPropertyValue(property);
21
+ }
22
+
23
+ describe("Token Resolution", () => {
24
+ describe("Semantic Color Tokens", () => {
25
+ test("Button primary variant uses semantic bg token", async () => {
26
+ render(<Button data-testid="btn">Primary Button</Button>);
27
+ const button = page.getByTestId("btn");
28
+ await expect.element(button).toBeInTheDocument();
29
+
30
+ const element = button.element();
31
+ const bgColor = getComputedStyleValue(element, "background-color");
32
+
33
+ // The button should have a non-transparent background
34
+ expect(bgColor).not.toBe("rgba(0, 0, 0, 0)");
35
+ expect(bgColor).not.toBe("transparent");
36
+ });
37
+
38
+ test("Button text color resolves from semantic token", async () => {
39
+ render(<Button data-testid="btn">Text Color</Button>);
40
+ const button = page.getByTestId("btn");
41
+ await expect.element(button).toBeInTheDocument();
42
+
43
+ const element = button.element();
44
+ const textColor = getComputedStyleValue(element, "color");
45
+
46
+ // Should have a defined text color
47
+ expect(textColor).toBeTruthy();
48
+ expect(textColor).not.toBe("rgba(0, 0, 0, 0)");
49
+ });
50
+
51
+ test("Background page token resolves correctly", async () => {
52
+ render(
53
+ <div data-testid="bg-test" className="bg-bg-page p-16">
54
+ Background Test
55
+ </div>,
56
+ );
57
+ const element = page.getByTestId("bg-test").element();
58
+ const bgColor = getComputedStyleValue(element, "background-color");
59
+
60
+ // bg-page should resolve to a non-transparent color
61
+ expect(bgColor).not.toBe("rgba(0, 0, 0, 0)");
62
+ });
63
+
64
+ test("Text primary token resolves correctly", async () => {
65
+ render(
66
+ <p data-testid="text-test" className="text-text-primary">
67
+ Primary Text
68
+ </p>,
69
+ );
70
+ const element = page.getByTestId("text-test").element();
71
+ const textColor = getComputedStyleValue(element, "color");
72
+
73
+ // text-primary should be a dark color
74
+ expect(textColor).toBeTruthy();
75
+ expect(textColor).not.toBe("rgba(0, 0, 0, 0)");
76
+ });
77
+ });
78
+
79
+ describe("Primitive Color Tokens", () => {
80
+ test("Gray scale colors resolve", async () => {
81
+ render(
82
+ <div>
83
+ <div data-testid="gray-100" className="bg-gray-100">
84
+ Gray 100
85
+ </div>
86
+ <div data-testid="gray-500" className="bg-gray-500">
87
+ Gray 500
88
+ </div>
89
+ <div data-testid="gray-900" className="bg-gray-900">
90
+ Gray 900
91
+ </div>
92
+ </div>,
93
+ );
94
+
95
+ const gray100 = page.getByTestId("gray-100").element();
96
+ const gray500 = page.getByTestId("gray-500").element();
97
+ const gray900 = page.getByTestId("gray-900").element();
98
+
99
+ const bg100 = getComputedStyleValue(gray100, "background-color");
100
+ const bg500 = getComputedStyleValue(gray500, "background-color");
101
+ const bg900 = getComputedStyleValue(gray900, "background-color");
102
+
103
+ // All should have resolved background colors
104
+ expect(bg100).not.toBe("rgba(0, 0, 0, 0)");
105
+ expect(bg500).not.toBe("rgba(0, 0, 0, 0)");
106
+ expect(bg900).not.toBe("rgba(0, 0, 0, 0)");
107
+
108
+ // Gray 100 should be lighter than Gray 900
109
+ // (comparing luminance would be ideal, but we'll just verify they're different)
110
+ expect(bg100).not.toBe(bg900);
111
+ });
112
+
113
+ test("Indigo accent colors resolve", async () => {
114
+ render(
115
+ <div data-testid="indigo" className="bg-indigo-600">
116
+ Indigo 600
117
+ </div>,
118
+ );
119
+
120
+ const element = page.getByTestId("indigo").element();
121
+ const bgColor = getComputedStyleValue(element, "background-color");
122
+
123
+ // Should resolve to a blue-ish color (indigo)
124
+ expect(bgColor).not.toBe("rgba(0, 0, 0, 0)");
125
+ });
126
+ });
127
+
128
+ describe("Typography Tokens", () => {
129
+ test("Typography h1 class applies correct styles", async () => {
130
+ render(
131
+ <h1 data-testid="h1" className="typography-h1">
132
+ Heading 1
133
+ </h1>,
134
+ );
135
+
136
+ const element = page.getByTestId("h1").element();
137
+ const fontSize = getComputedStyleValue(element, "font-size");
138
+ const fontFamily = getComputedStyleValue(element, "font-family");
139
+
140
+ const sizeNum = Number.parseFloat(fontSize);
141
+ expect(sizeNum).toBeGreaterThan(0);
142
+
143
+ expect(fontFamily).toBeTruthy();
144
+ });
145
+
146
+ test("Typography body-medium class applies correct styles", async () => {
147
+ render(
148
+ <p data-testid="body" className="typography-body-medium">
149
+ Body text
150
+ </p>,
151
+ );
152
+
153
+ const element = page.getByTestId("body").element();
154
+ const fontSize = getComputedStyleValue(element, "font-size");
155
+ const lineHeight = getComputedStyleValue(element, "line-height");
156
+
157
+ // Body medium should be around 14-18px depending on breakpoint
158
+ const sizeNum = Number.parseFloat(fontSize);
159
+ expect(sizeNum).toBeGreaterThanOrEqual(14);
160
+ expect(sizeNum).toBeLessThanOrEqual(18);
161
+
162
+ // Should have a line height set
163
+ expect(lineHeight).toBeTruthy();
164
+ });
165
+
166
+ test("Typography button class applies correct weight", async () => {
167
+ render(
168
+ <span data-testid="btn-text" className="typography-ui-button-medium">
169
+ Button Text
170
+ </span>,
171
+ );
172
+
173
+ const element = page.getByTestId("btn-text").element();
174
+ const fontWeight = getComputedStyleValue(element, "font-weight");
175
+
176
+ const weightNum = Number.parseInt(fontWeight, 10);
177
+ expect(weightNum).toBeGreaterThan(0);
178
+ });
179
+ });
180
+
181
+ describe("Spacing Tokens", () => {
182
+ test("Padding tokens resolve to correct pixel values", async () => {
183
+ render(
184
+ <div data-testid="padded" className="p-16">
185
+ Padded content
186
+ </div>,
187
+ );
188
+
189
+ const element = page.getByTestId("padded").element();
190
+ const padding = getComputedStyleValue(element, "padding");
191
+
192
+ // p-16 should resolve to 16px on all sides
193
+ expect(padding).toBe("16px");
194
+ });
195
+
196
+ test("Margin tokens resolve correctly", async () => {
197
+ render(
198
+ <div data-testid="margin" className="m-24">
199
+ Margin content
200
+ </div>,
201
+ );
202
+
203
+ const element = page.getByTestId("margin").element();
204
+ const margin = getComputedStyleValue(element, "margin");
205
+
206
+ // m-24 should resolve to 24px on all sides
207
+ expect(margin).toBe("24px");
208
+ });
209
+
210
+ test("Gap tokens resolve correctly", async () => {
211
+ render(
212
+ <div data-testid="gap" className="flex gap-8">
213
+ <span>Item 1</span>
214
+ <span>Item 2</span>
215
+ </div>,
216
+ );
217
+
218
+ const element = page.getByTestId("gap").element();
219
+ const gap = getComputedStyleValue(element, "gap");
220
+
221
+ // gap-8 should resolve to 8px
222
+ expect(gap).toBe("8px");
223
+ });
224
+
225
+ test("Width spacing tokens resolve", async () => {
226
+ render(
227
+ <div data-testid="width" className="w-64 h-8 bg-gray-500">
228
+ Width test
229
+ </div>,
230
+ );
231
+
232
+ const element = page.getByTestId("width").element();
233
+ const width = getComputedStyleValue(element, "width");
234
+
235
+ // w-64 should be 64px
236
+ expect(width).toBe("64px");
237
+ });
238
+ });
239
+
240
+ describe("Border Radius Tokens", () => {
241
+ test("Radius tokens resolve correctly", async () => {
242
+ render(
243
+ <div data-testid="rounded" className="rounded-8 bg-gray-200 p-16">
244
+ Rounded corners
245
+ </div>,
246
+ );
247
+
248
+ const element = page.getByTestId("rounded").element();
249
+ const radius = getComputedStyleValue(element, "border-radius");
250
+
251
+ // rounded-8 should be 8px
252
+ expect(radius).toBe("8px");
253
+ });
254
+ });
255
+
256
+ describe("Component Token Integration", () => {
257
+ test("Button has defined height from spacing tokens", async () => {
258
+ render(
259
+ <Button data-testid="btn" size="md">
260
+ Medium Button
261
+ </Button>,
262
+ );
263
+ const button = page.getByTestId("btn");
264
+ await expect.element(button).toBeInTheDocument();
265
+
266
+ const element = button.element();
267
+ const height = getComputedStyleValue(element, "height");
268
+
269
+ // Button should have a defined height (not auto)
270
+ const heightNum = Number.parseFloat(height);
271
+ expect(heightNum).toBeGreaterThan(0);
272
+ });
273
+
274
+ test("Button sizes use different padding tokens", async () => {
275
+ render(
276
+ <div>
277
+ <Button data-testid="btn-sm" size="sm">
278
+ Small
279
+ </Button>
280
+ <Button data-testid="btn-lg" size="lg">
281
+ Large
282
+ </Button>
283
+ </div>,
284
+ );
285
+
286
+ const smallBtn = page.getByTestId("btn-sm").element();
287
+ const largeBtn = page.getByTestId("btn-lg").element();
288
+
289
+ const smallPadding = getComputedStyleValue(smallBtn, "padding-left");
290
+ const largePadding = getComputedStyleValue(largeBtn, "padding-left");
291
+
292
+ // Large should have more padding than small
293
+ const smallPaddingNum = Number.parseFloat(smallPadding);
294
+ const largePaddingNum = Number.parseFloat(largePadding);
295
+ expect(largePaddingNum).toBeGreaterThan(smallPaddingNum);
296
+ });
297
+ });
298
+ });
@@ -9,7 +9,7 @@ import type {
9
9
  NestedStringRecord,
10
10
  } from "@nds-design-system/tokens";
11
11
  import { useContext } from "react";
12
- import { ThemeContext, type ThemeContextValue } from "./ThemeProvider";
12
+ import { ThemeContext, type ThemeContextValue } from "./theme-provider";
13
13
 
14
14
  /**
15
15
  * Hook to access the theme context
@@ -30,7 +30,7 @@ export {
30
30
  type ThemeContextValue,
31
31
  ThemeProvider,
32
32
  type ThemeProviderProps,
33
- } from "./ThemeProvider";
33
+ } from "./theme-provider";
34
34
  // Utilities
35
35
  export {
36
36
  applyTheme,
@@ -0,0 +1,270 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { page } from "vitest/browser";
3
+ import { render } from "vitest-browser-react";
4
+ import { Button } from "../components/atoms/button/button";
5
+ import { useCSSVars, useTheme } from "./hooks";
6
+ import { ThemeProvider } from "./theme-provider";
7
+
8
+ function getInlineStyleVar(element: Element, varName: string): string | null {
9
+ const style = element.getAttribute("style") || "";
10
+ const regex = new RegExp(`${varName}:\\s*([^;]+)`);
11
+ const match = style.match(regex);
12
+ return match ? match[1].trim() : null;
13
+ }
14
+
15
+ describe("ThemeProvider", () => {
16
+ describe("CSS Variable Application", () => {
17
+ test("renders a wrapper div with style attribute", async () => {
18
+ render(
19
+ <ThemeProvider>
20
+ <div data-testid="content">Content</div>
21
+ </ThemeProvider>,
22
+ );
23
+
24
+ const content = page.getByTestId("content");
25
+ await expect.element(content).toBeInTheDocument();
26
+
27
+ const wrapper = content.element().parentElement;
28
+ expect(wrapper).toBeTruthy();
29
+ expect(wrapper?.tagName).toBe("DIV");
30
+ expect(wrapper?.hasAttribute("style")).toBe(true);
31
+
32
+ const styleAttr = wrapper?.getAttribute("style") || "";
33
+ expect(styleAttr).toContain("--color-bg-page");
34
+ });
35
+
36
+ test("applies className to wrapper div", async () => {
37
+ render(
38
+ <ThemeProvider className="custom-class">
39
+ <div data-testid="content">Content</div>
40
+ </ThemeProvider>,
41
+ );
42
+
43
+ const content = page.getByTestId("content");
44
+ const wrapper = content.element().parentElement;
45
+ expect(wrapper?.classList.contains("custom-class")).toBe(true);
46
+ });
47
+
48
+ test("applyStyles=false skips wrapper div", async () => {
49
+ function TestContent() {
50
+ const theme = useTheme();
51
+ return <div data-testid="content">{theme.colorTheme}</div>;
52
+ }
53
+
54
+ render(
55
+ <ThemeProvider applyStyles={false}>
56
+ <TestContent />
57
+ </ThemeProvider>,
58
+ );
59
+
60
+ const content = page.getByTestId("content");
61
+ await expect.element(content).toBeInTheDocument();
62
+ expect(content.element().textContent).toBe("base");
63
+ });
64
+ });
65
+
66
+ describe("Color Theme Overrides", () => {
67
+ test("base theme applies CSS variables for buttons", async () => {
68
+ render(
69
+ <ThemeProvider color="base">
70
+ <Button data-testid="btn" variant="primary">
71
+ Primary
72
+ </Button>
73
+ </ThemeProvider>,
74
+ );
75
+
76
+ const button = page.getByTestId("btn");
77
+ await expect.element(button).toBeInTheDocument();
78
+
79
+ const wrapper = button.element().parentElement;
80
+ const styleAttr = wrapper?.getAttribute("style") || "";
81
+ expect(styleAttr).toContain("--color-button-primary-bg");
82
+ });
83
+
84
+ test("civic theme overrides button primary color", async () => {
85
+ render(
86
+ <ThemeProvider color="civic">
87
+ <Button data-testid="btn" variant="primary">
88
+ Primary
89
+ </Button>
90
+ </ThemeProvider>,
91
+ );
92
+
93
+ const button = page.getByTestId("btn");
94
+ await expect.element(button).toBeInTheDocument();
95
+
96
+ const wrapper = button.element().parentElement;
97
+ expect(wrapper).toBeTruthy();
98
+
99
+ const styleAttr = wrapper?.getAttribute("style") || "";
100
+ expect(styleAttr).toContain("--color-button-primary-bg");
101
+ expect(styleAttr).toContain("red");
102
+ });
103
+
104
+ test("dark theme applies dark mode colors", async () => {
105
+ render(
106
+ <ThemeProvider color="dark">
107
+ <div data-testid="bg" className="bg-bg-page p-16">
108
+ Dark Theme
109
+ </div>
110
+ </ThemeProvider>,
111
+ );
112
+
113
+ const bgElement = page.getByTestId("bg");
114
+ await expect.element(bgElement).toBeInTheDocument();
115
+
116
+ const wrapper = bgElement.element().parentElement;
117
+ const styleAttr = wrapper?.getAttribute("style") || "";
118
+ expect(styleAttr).toContain("--color-bg-page");
119
+ });
120
+
121
+ test("institution theme uses green button colors", async () => {
122
+ render(
123
+ <ThemeProvider color="institution">
124
+ <Button data-testid="btn" variant="primary">
125
+ Institution
126
+ </Button>
127
+ </ThemeProvider>,
128
+ );
129
+
130
+ const button = page.getByTestId("btn");
131
+ await expect.element(button).toBeInTheDocument();
132
+
133
+ const wrapper = button.element().parentElement;
134
+ const styleAttr = wrapper?.getAttribute("style") || "";
135
+ expect(styleAttr).toContain("--color-button-primary-bg");
136
+ expect(styleAttr).toContain("green");
137
+ });
138
+ });
139
+
140
+ describe("Theme Context", () => {
141
+ test("useTheme returns current theme names", async () => {
142
+ function ThemeInfo() {
143
+ const { colorTheme, surfaceTheme } = useTheme();
144
+ return (
145
+ <div data-testid="info">
146
+ {colorTheme}-{surfaceTheme}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ render(
152
+ <ThemeProvider color="civic" surface="sharp">
153
+ <ThemeInfo />
154
+ </ThemeProvider>,
155
+ );
156
+
157
+ const info = page.getByTestId("info");
158
+ await expect.element(info).toBeInTheDocument();
159
+ expect(info.element().textContent).toBe("civic-sharp");
160
+ });
161
+
162
+ test("useCSSVars returns CSS variable map", async () => {
163
+ function CSSVarsTest() {
164
+ const cssVars = useCSSVars();
165
+ const hasVars = Object.keys(cssVars).length > 0;
166
+ return (
167
+ <div data-testid="result">{hasVars ? "has-vars" : "no-vars"}</div>
168
+ );
169
+ }
170
+
171
+ render(
172
+ <ThemeProvider>
173
+ <CSSVarsTest />
174
+ </ThemeProvider>,
175
+ );
176
+
177
+ const result = page.getByTestId("result");
178
+ await expect.element(result).toBeInTheDocument();
179
+ expect(result.element().textContent).toBe("has-vars");
180
+ });
181
+ });
182
+
183
+ describe("Nested ThemeProviders", () => {
184
+ test("nested ThemeProvider overrides parent theme", async () => {
185
+ function ThemeInfo() {
186
+ const { colorTheme } = useTheme();
187
+ return <span data-testid="theme">{colorTheme}</span>;
188
+ }
189
+
190
+ render(
191
+ <ThemeProvider color="base">
192
+ <div>
193
+ <ThemeInfo />
194
+ <ThemeProvider color="civic">
195
+ <ThemeInfo />
196
+ </ThemeProvider>
197
+ </div>
198
+ </ThemeProvider>,
199
+ );
200
+
201
+ const themes = page.getByTestId("theme");
202
+ await expect.element(themes.first()).toBeInTheDocument();
203
+
204
+ const themeElements = document.querySelectorAll('[data-testid="theme"]');
205
+ expect(themeElements[0].textContent).toBe("base");
206
+ expect(themeElements[1].textContent).toBe("civic");
207
+ });
208
+ });
209
+
210
+ describe("Button Theming Integration", () => {
211
+ test("button primary-outline inherits theme colors", async () => {
212
+ render(
213
+ <ThemeProvider color="civic">
214
+ <Button data-testid="btn" variant="primary-outline">
215
+ Outline
216
+ </Button>
217
+ </ThemeProvider>,
218
+ );
219
+
220
+ const button = page.getByTestId("btn");
221
+ await expect.element(button).toBeInTheDocument();
222
+
223
+ const wrapper = button.element().parentElement;
224
+ const styleAttr = wrapper?.getAttribute("style") || "";
225
+ expect(styleAttr).toContain("--color-button-primary-outline");
226
+ });
227
+
228
+ test("different themes produce different CSS variables", async () => {
229
+ render(
230
+ <div className="flex gap-16">
231
+ <ThemeProvider color="base">
232
+ <Button data-testid="base-btn" variant="primary">
233
+ Base
234
+ </Button>
235
+ </ThemeProvider>
236
+ <ThemeProvider color="civic">
237
+ <Button data-testid="civic-btn" variant="primary">
238
+ Civic
239
+ </Button>
240
+ </ThemeProvider>
241
+ </div>,
242
+ );
243
+
244
+ const baseBtn = page.getByTestId("base-btn");
245
+ const civicBtn = page.getByTestId("civic-btn");
246
+
247
+ await expect.element(baseBtn).toBeInTheDocument();
248
+ await expect.element(civicBtn).toBeInTheDocument();
249
+
250
+ const baseWrapper = baseBtn.element().parentElement;
251
+ const civicWrapper = civicBtn.element().parentElement;
252
+
253
+ expect(baseWrapper).toBeTruthy();
254
+ expect(civicWrapper).toBeTruthy();
255
+
256
+ const baseBgVar = getInlineStyleVar(
257
+ baseWrapper as Element,
258
+ "--color-button-primary-bg",
259
+ );
260
+ const civicBgVar = getInlineStyleVar(
261
+ civicWrapper as Element,
262
+ "--color-button-primary-bg",
263
+ );
264
+
265
+ expect(baseBgVar).toBeTruthy();
266
+ expect(civicBgVar).toBeTruthy();
267
+ expect(baseBgVar).not.toBe(civicBgVar);
268
+ });
269
+ });
270
+ });
@@ -48,6 +48,10 @@ export interface ThemeProviderProps {
48
48
  surface?: SurfaceThemeName;
49
49
  /** Children to render */
50
50
  children: ReactNode;
51
+ /** Optional className for the wrapper div */
52
+ className?: string;
53
+ /** Whether to render a wrapper div with CSS variables applied (defaults to true) */
54
+ applyStyles?: boolean;
51
55
  }
52
56
 
53
57
  /**
@@ -105,6 +109,8 @@ function processColorTokens(
105
109
  if (typeof tokenValue === "string" && tokenValue.startsWith("{")) {
106
110
  // Alias reference - resolve to CSS var
107
111
  let refPath = tokenValue.slice(1, -1).replace(/\./g, "-");
112
+ // Strip prefixes that aren't used in CSS variable names
113
+ refPath = refPath.replace(/^primitive-/, "");
108
114
  refPath = refPath.replace(/^semantic-color-/, "color-");
109
115
  result[varName] = `var(--${refPath})`;
110
116
  } else {
@@ -160,7 +166,9 @@ function processSurfaceTokens(
160
166
  if (typeof tokenValue === "string" && tokenValue.startsWith("{")) {
161
167
  // Alias reference
162
168
  let refPath = tokenValue.slice(1, -1).replace(/\./g, "-");
163
- refPath = refPath.replace(/^primitive-radii-/, "radius-");
169
+ // Strip prefixes that aren't used in CSS variable names
170
+ refPath = refPath.replace(/^primitive-/, "");
171
+ refPath = refPath.replace(/^radii-/, "radius-");
164
172
  cssValue = `var(--${refPath})`;
165
173
  } else {
166
174
  cssValue = tokenValueToCSS(tokenValue);
@@ -241,6 +249,8 @@ export function ThemeProvider({
241
249
  color = "base",
242
250
  surface = "base",
243
251
  children,
252
+ className,
253
+ applyStyles = true,
244
254
  }: ThemeProviderProps) {
245
255
  const { tokens, cssVars } = useMemo(() => {
246
256
  const flatTokens: Record<string, string> = {};
@@ -291,7 +301,13 @@ export function ThemeProvider({
291
301
 
292
302
  return (
293
303
  <ThemeContext.Provider value={contextValue}>
294
- {children}
304
+ {applyStyles ? (
305
+ <div style={cssVars} className={className}>
306
+ {children}
307
+ </div>
308
+ ) : (
309
+ children
310
+ )}
295
311
  </ThemeContext.Provider>
296
312
  );
297
313
  }