mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
style: format all files with prettier
This commit is contained in:
@@ -20,6 +20,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
## Core Capabilities
|
||||
|
||||
### 1. Design Tokens
|
||||
|
||||
- Primitive tokens (raw values: colors, sizes, fonts)
|
||||
- Semantic tokens (contextual meaning: text-primary, surface-elevated)
|
||||
- Component tokens (specific usage: button-bg, card-border)
|
||||
@@ -27,6 +28,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Multi-platform token generation (CSS, iOS, Android)
|
||||
|
||||
### 2. Theming Infrastructure
|
||||
|
||||
- CSS custom properties architecture
|
||||
- Theme context providers in React
|
||||
- Dynamic theme switching
|
||||
@@ -35,6 +37,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Reduced motion and high contrast modes
|
||||
|
||||
### 3. Component Architecture
|
||||
|
||||
- Compound component patterns
|
||||
- Polymorphic components (as prop)
|
||||
- Variant and size systems
|
||||
@@ -43,6 +46,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Style props and responsive variants
|
||||
|
||||
### 4. Token Pipeline
|
||||
|
||||
- Figma to code synchronization
|
||||
- Style Dictionary configuration
|
||||
- Token transformation and formatting
|
||||
@@ -56,32 +60,32 @@ const tokens = {
|
||||
colors: {
|
||||
// Primitive tokens
|
||||
gray: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
900: '#171717',
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
900: "#171717",
|
||||
},
|
||||
blue: {
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
},
|
||||
},
|
||||
// Semantic tokens (reference primitives)
|
||||
semantic: {
|
||||
light: {
|
||||
'text-primary': 'var(--color-gray-900)',
|
||||
'text-secondary': 'var(--color-gray-600)',
|
||||
'surface-default': 'var(--color-white)',
|
||||
'surface-elevated': 'var(--color-gray-50)',
|
||||
'border-default': 'var(--color-gray-200)',
|
||||
'interactive-primary': 'var(--color-blue-500)',
|
||||
"text-primary": "var(--color-gray-900)",
|
||||
"text-secondary": "var(--color-gray-600)",
|
||||
"surface-default": "var(--color-white)",
|
||||
"surface-elevated": "var(--color-gray-50)",
|
||||
"border-default": "var(--color-gray-200)",
|
||||
"interactive-primary": "var(--color-blue-500)",
|
||||
},
|
||||
dark: {
|
||||
'text-primary': 'var(--color-gray-50)',
|
||||
'text-secondary': 'var(--color-gray-400)',
|
||||
'surface-default': 'var(--color-gray-900)',
|
||||
'surface-elevated': 'var(--color-gray-800)',
|
||||
'border-default': 'var(--color-gray-700)',
|
||||
'interactive-primary': 'var(--color-blue-400)',
|
||||
"text-primary": "var(--color-gray-50)",
|
||||
"text-secondary": "var(--color-gray-400)",
|
||||
"surface-default": "var(--color-gray-900)",
|
||||
"surface-elevated": "var(--color-gray-800)",
|
||||
"border-default": "var(--color-gray-700)",
|
||||
"interactive-primary": "var(--color-blue-400)",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -135,13 +139,13 @@ const tokens = {
|
||||
### Pattern 2: Theme Switching with React
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
@@ -149,37 +153,37 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as Theme) || "system";
|
||||
}
|
||||
return 'system';
|
||||
return "system";
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const applyTheme = (isDark: boolean) => {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(isDark ? 'dark' : 'light');
|
||||
setResolvedTheme(isDark ? 'dark' : 'light');
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
setResolvedTheme(isDark ? "dark" : "light");
|
||||
};
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
if (theme === "system") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
applyTheme(mediaQuery.matches);
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
} else {
|
||||
applyTheme(theme === 'dark');
|
||||
applyTheme(theme === "dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme', theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
@@ -191,7 +195,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
||||
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return context;
|
||||
};
|
||||
```
|
||||
@@ -199,45 +203,52 @@ export const useTheme = () => {
|
||||
### Pattern 3: Variant System with CVA
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-11 px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -247,44 +258,52 @@ export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
```javascript
|
||||
// style-dictionary.config.js
|
||||
module.exports = {
|
||||
source: ['tokens/**/*.json'],
|
||||
source: ["tokens/**/*.json"],
|
||||
platforms: {
|
||||
css: {
|
||||
transformGroup: 'css',
|
||||
buildPath: 'dist/css/',
|
||||
files: [{
|
||||
destination: 'variables.css',
|
||||
format: 'css/variables',
|
||||
options: {
|
||||
outputReferences: true, // Preserve token references
|
||||
transformGroup: "css",
|
||||
buildPath: "dist/css/",
|
||||
files: [
|
||||
{
|
||||
destination: "variables.css",
|
||||
format: "css/variables",
|
||||
options: {
|
||||
outputReferences: true, // Preserve token references
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
},
|
||||
scss: {
|
||||
transformGroup: 'scss',
|
||||
buildPath: 'dist/scss/',
|
||||
files: [{
|
||||
destination: '_variables.scss',
|
||||
format: 'scss/variables',
|
||||
}],
|
||||
transformGroup: "scss",
|
||||
buildPath: "dist/scss/",
|
||||
files: [
|
||||
{
|
||||
destination: "_variables.scss",
|
||||
format: "scss/variables",
|
||||
},
|
||||
],
|
||||
},
|
||||
ios: {
|
||||
transformGroup: 'ios-swift',
|
||||
buildPath: 'dist/ios/',
|
||||
files: [{
|
||||
destination: 'DesignTokens.swift',
|
||||
format: 'ios-swift/class.swift',
|
||||
className: 'DesignTokens',
|
||||
}],
|
||||
transformGroup: "ios-swift",
|
||||
buildPath: "dist/ios/",
|
||||
files: [
|
||||
{
|
||||
destination: "DesignTokens.swift",
|
||||
format: "ios-swift/class.swift",
|
||||
className: "DesignTokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
transformGroup: 'android',
|
||||
buildPath: 'dist/android/',
|
||||
files: [{
|
||||
destination: 'colors.xml',
|
||||
format: 'android/colors',
|
||||
filter: { attributes: { category: 'color' } },
|
||||
}],
|
||||
transformGroup: "android",
|
||||
buildPath: "dist/android/",
|
||||
files: [
|
||||
{
|
||||
destination: "colors.xml",
|
||||
format: "android/colors",
|
||||
filter: { attributes: { category: "color" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,20 +10,22 @@ Compound components share implicit state through React context, allowing flexibl
|
||||
|
||||
```tsx
|
||||
// Compound component pattern
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
type: 'single' | 'multiple';
|
||||
type: "single" | "multiple";
|
||||
}
|
||||
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useAccordionContext() {
|
||||
const context = React.useContext(AccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('Accordion components must be used within an Accordion');
|
||||
throw new Error("Accordion components must be used within an Accordion");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -31,13 +33,17 @@ function useAccordionContext() {
|
||||
// Root component
|
||||
interface AccordionProps {
|
||||
children: React.ReactNode;
|
||||
type?: 'single' | 'multiple';
|
||||
type?: "single" | "multiple";
|
||||
defaultOpen?: string[];
|
||||
}
|
||||
|
||||
function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionProps) {
|
||||
function Accordion({
|
||||
children,
|
||||
type = "single",
|
||||
defaultOpen = [],
|
||||
}: AccordionProps) {
|
||||
const [openItems, setOpenItems] = React.useState<Set<string>>(
|
||||
new Set(defaultOpen)
|
||||
new Set(defaultOpen),
|
||||
);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
@@ -47,7 +53,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
if (type === 'single') {
|
||||
if (type === "single") {
|
||||
next.clear();
|
||||
}
|
||||
next.add(id);
|
||||
@@ -55,7 +61,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[type]
|
||||
[type],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -93,7 +99,7 @@ function AccordionTrigger({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -120,7 +126,7 @@ export const AccordionCompound = Object.assign(Accordion, {
|
||||
// Usage
|
||||
function Example() {
|
||||
return (
|
||||
<AccordionCompound type="single" defaultOpen={['item-1']}>
|
||||
<AccordionCompound type="single" defaultOpen={["item-1"]}>
|
||||
<AccordionCompound.Item id="item-1">
|
||||
<AccordionCompound.Trigger>Is it accessible?</AccordionCompound.Trigger>
|
||||
<AccordionCompound.Content>
|
||||
@@ -144,7 +150,7 @@ Polymorphic components can render as different HTML elements or other components
|
||||
|
||||
```tsx
|
||||
// Polymorphic component with proper TypeScript support
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type AsProp<C extends React.ElementType> = {
|
||||
as?: C;
|
||||
@@ -154,64 +160,71 @@ type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
|
||||
|
||||
type PolymorphicComponentProp<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
Props = {},
|
||||
> = React.PropsWithChildren<Props & AsProp<C>> &
|
||||
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
|
||||
|
||||
type PolymorphicRef<C extends React.ElementType> =
|
||||
React.ComponentPropsWithRef<C>['ref'];
|
||||
React.ComponentPropsWithRef<C>["ref"];
|
||||
|
||||
type PolymorphicComponentPropWithRef<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
Props = {},
|
||||
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
|
||||
|
||||
// Button component
|
||||
interface ButtonOwnProps {
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: "default" | "outline" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
type ButtonProps<C extends React.ElementType = 'button'> =
|
||||
type ButtonProps<C extends React.ElementType = "button"> =
|
||||
PolymorphicComponentPropWithRef<C, ButtonOwnProps>;
|
||||
|
||||
const Button = React.forwardRef(
|
||||
<C extends React.ElementType = 'button'>(
|
||||
{ as, variant = 'default', size = 'md', className, children, ...props }: ButtonProps<C>,
|
||||
ref?: PolymorphicRef<C>
|
||||
<C extends React.ElementType = "button">(
|
||||
{
|
||||
as,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps<C>,
|
||||
ref?: PolymorphicRef<C>,
|
||||
) => {
|
||||
const Component = as || 'button';
|
||||
const Component = as || "button";
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline: "border border-input bg-background hover:bg-accent",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
|
||||
// Usage
|
||||
function Example() {
|
||||
@@ -242,31 +255,31 @@ Slots allow users to replace default elements with custom implementations.
|
||||
|
||||
```tsx
|
||||
// Slot pattern for customizable components
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
variant?: 'default' | 'outline';
|
||||
variant?: "default" | "outline";
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ asChild = false, variant = 'default', className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
({ asChild = false, variant = "default", className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
variant === 'default' && 'bg-primary text-primary-foreground',
|
||||
variant === 'outline' && 'border border-input bg-background',
|
||||
className
|
||||
"inline-flex items-center justify-center rounded-md font-medium",
|
||||
variant === "default" && "bg-primary text-primary-foreground",
|
||||
variant === "outline" && "border border-input bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Usage - Button styles applied to child element
|
||||
@@ -285,7 +298,7 @@ Headless components provide behavior without styling, enabling complete visual c
|
||||
|
||||
```tsx
|
||||
// Headless toggle hook
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
interface UseToggleProps {
|
||||
defaultPressed?: boolean;
|
||||
@@ -315,8 +328,8 @@ function useToggle({
|
||||
pressed,
|
||||
toggle,
|
||||
buttonProps: {
|
||||
role: 'switch' as const,
|
||||
'aria-checked': pressed,
|
||||
role: "switch" as const,
|
||||
"aria-checked": pressed,
|
||||
onClick: toggle,
|
||||
},
|
||||
};
|
||||
@@ -334,7 +347,8 @@ function useListbox<T>({
|
||||
defaultSelectedIndex = -1,
|
||||
onSelect,
|
||||
}: UseListboxProps<T>) {
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(defaultSelectedIndex);
|
||||
const [selectedIndex, setSelectedIndex] =
|
||||
React.useState(defaultSelectedIndex);
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
||||
|
||||
const select = React.useCallback(
|
||||
@@ -342,40 +356,40 @@ function useListbox<T>({
|
||||
setSelectedIndex(index);
|
||||
onSelect?.(items[index], index);
|
||||
},
|
||||
[items, onSelect]
|
||||
[items, onSelect],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < items.length - 1 ? prev + 1 : prev
|
||||
prev < items.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
select(highlightedIndex);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(items.length - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[items.length, highlightedIndex, select]
|
||||
[items.length, highlightedIndex, select],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -384,13 +398,13 @@ function useListbox<T>({
|
||||
select,
|
||||
setHighlightedIndex,
|
||||
listboxProps: {
|
||||
role: 'listbox' as const,
|
||||
role: "listbox" as const,
|
||||
tabIndex: 0,
|
||||
onKeyDown: handleKeyDown,
|
||||
},
|
||||
getOptionProps: (index: number) => ({
|
||||
role: 'option' as const,
|
||||
'aria-selected': index === selectedIndex,
|
||||
role: "option" as const,
|
||||
"aria-selected": index === selectedIndex,
|
||||
onClick: () => select(index),
|
||||
onMouseEnter: () => setHighlightedIndex(index),
|
||||
}),
|
||||
@@ -461,28 +475,28 @@ function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
## Responsive Variants
|
||||
|
||||
```tsx
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
// Responsive variant configuration
|
||||
const containerVariants = cva('mx-auto w-full px-4', {
|
||||
const containerVariants = cva("mx-auto w-full px-4", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
full: 'max-w-full',
|
||||
sm: "max-w-screen-sm",
|
||||
md: "max-w-screen-md",
|
||||
lg: "max-w-screen-lg",
|
||||
xl: "max-w-screen-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
padding: {
|
||||
none: 'px-0',
|
||||
sm: 'px-4 md:px-6',
|
||||
md: 'px-4 md:px-8 lg:px-12',
|
||||
lg: 'px-6 md:px-12 lg:px-20',
|
||||
none: "px-0",
|
||||
sm: "px-4 md:px-6",
|
||||
md: "px-4 md:px-8 lg:px-12",
|
||||
lg: "px-6 md:px-12 lg:px-20",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'lg',
|
||||
padding: 'md',
|
||||
size: "lg",
|
||||
padding: "md",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -498,23 +512,23 @@ interface ResponsiveValue<T> {
|
||||
function getResponsiveClasses<T extends string>(
|
||||
prop: T | ResponsiveValue<T> | undefined,
|
||||
classMap: Record<T, string>,
|
||||
responsiveClassMap: Record<string, Record<T, string>>
|
||||
responsiveClassMap: Record<string, Record<T, string>>,
|
||||
): string {
|
||||
if (!prop) return '';
|
||||
if (!prop) return "";
|
||||
|
||||
if (typeof prop === 'string') {
|
||||
if (typeof prop === "string") {
|
||||
return classMap[prop];
|
||||
}
|
||||
|
||||
return Object.entries(prop)
|
||||
.map(([breakpoint, value]) => {
|
||||
if (breakpoint === 'base') {
|
||||
if (breakpoint === "base") {
|
||||
return classMap[value as T];
|
||||
}
|
||||
return responsiveClassMap[breakpoint]?.[value as T];
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
}
|
||||
```
|
||||
|
||||
@@ -555,7 +569,7 @@ function DataList<T>({
|
||||
keyExtractor={(user) => user.id}
|
||||
renderItem={(user) => <UserCard user={user} />}
|
||||
renderEmpty={() => <EmptyState message="No users found" />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
### Children as Function
|
||||
@@ -577,11 +591,11 @@ function Disclosure({ children, defaultOpen = false }: DisclosureProps) {
|
||||
<Disclosure>
|
||||
{({ isOpen, toggle }) => (
|
||||
<>
|
||||
<button onClick={toggle}>{isOpen ? 'Close' : 'Open'}</button>
|
||||
<button onClick={toggle}>{isOpen ? "Close" : "Open"}</button>
|
||||
{isOpen && <div>Content</div>}
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
</Disclosure>;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -129,9 +129,15 @@ Design tokens are the atomic values of a design system - the smallest pieces tha
|
||||
{
|
||||
"shadow": {
|
||||
"sm": { "value": "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
|
||||
"md": { "value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" },
|
||||
"lg": { "value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)" },
|
||||
"xl": { "value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)" }
|
||||
"md": {
|
||||
"value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"lg": {
|
||||
"value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"xl": {
|
||||
"value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"none": { "value": "0" },
|
||||
@@ -302,13 +308,13 @@ Examples:
|
||||
### Style Dictionary Transforms
|
||||
|
||||
```javascript
|
||||
const StyleDictionary = require('style-dictionary');
|
||||
const StyleDictionary = require("style-dictionary");
|
||||
|
||||
// Custom transform for px to rem
|
||||
StyleDictionary.registerTransform({
|
||||
name: 'size/pxToRem',
|
||||
type: 'value',
|
||||
matcher: (token) => token.attributes.category === 'size',
|
||||
name: "size/pxToRem",
|
||||
type: "value",
|
||||
matcher: (token) => token.attributes.category === "size",
|
||||
transformer: (token) => {
|
||||
const value = parseFloat(token.value);
|
||||
return `${value / 16}rem`;
|
||||
@@ -317,14 +323,14 @@ StyleDictionary.registerTransform({
|
||||
|
||||
// Custom format for CSS custom properties
|
||||
StyleDictionary.registerFormat({
|
||||
name: 'css/customProperties',
|
||||
formatter: function({ dictionary, options }) {
|
||||
const tokens = dictionary.allTokens.map(token => {
|
||||
const name = token.name.replace(/\./g, '-');
|
||||
name: "css/customProperties",
|
||||
formatter: function ({ dictionary, options }) {
|
||||
const tokens = dictionary.allTokens.map((token) => {
|
||||
const name = token.name.replace(/\./g, "-");
|
||||
return ` --${name}: ${token.value};`;
|
||||
});
|
||||
|
||||
return `:root {\n${tokens.join('\n')}\n}`;
|
||||
return `:root {\n${tokens.join("\n")}\n}`;
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -399,10 +405,10 @@ interface TokenValidation {
|
||||
function validateContrast(
|
||||
foreground: string,
|
||||
background: string,
|
||||
level: 'AA' | 'AAA' = 'AA'
|
||||
level: "AA" | "AAA" = "AA",
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(foreground, background);
|
||||
return level === 'AA' ? ratio >= 4.5 : ratio >= 7;
|
||||
return level === "AA" ? ratio >= 4.5 : ratio >= 7;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* Base tokens that don't change */
|
||||
--font-sans: Inter, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* Animation tokens */
|
||||
--duration-fast: 150ms;
|
||||
@@ -34,7 +34,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* 2. Light theme (default) */
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
--color-bg-muted: #f1f5f9;
|
||||
@@ -57,7 +57,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
}
|
||||
|
||||
/* 3. Dark theme */
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0f172a;
|
||||
--color-bg-subtle: #1e293b;
|
||||
--color-bg-muted: #334155;
|
||||
@@ -81,7 +81,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* 4. System preference detection */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
:root:not([data-theme="light"]) {
|
||||
/* Inherit dark theme values */
|
||||
--color-bg: #0f172a;
|
||||
/* ... other dark values */
|
||||
@@ -129,16 +129,16 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
```tsx
|
||||
// theme-provider.tsx
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
attribute?: 'class' | 'data-theme';
|
||||
attribute?: "class" | "data-theme";
|
||||
enableSystem?: boolean;
|
||||
disableTransitionOnChange?: boolean;
|
||||
}
|
||||
@@ -150,31 +150,32 @@ interface ThemeProviderState {
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(
|
||||
undefined
|
||||
);
|
||||
const ThemeProviderContext = React.createContext<
|
||||
ThemeProviderState | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'theme',
|
||||
attribute = 'data-theme',
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
attribute = "data-theme",
|
||||
enableSystem = true,
|
||||
disableTransitionOnChange = false,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return defaultTheme;
|
||||
if (typeof window === "undefined") return defaultTheme;
|
||||
return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<ResolvedTheme>('light');
|
||||
const [resolvedTheme, setResolvedTheme] =
|
||||
React.useState<ResolvedTheme>("light");
|
||||
|
||||
// Get system preference
|
||||
const getSystemTheme = React.useCallback((): ResolvedTheme => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}, []);
|
||||
|
||||
// Apply theme to DOM
|
||||
@@ -184,11 +185,11 @@ export function ThemeProvider({
|
||||
|
||||
// Disable transitions temporarily
|
||||
if (disableTransitionOnChange) {
|
||||
const css = document.createElement('style');
|
||||
const css = document.createElement("style");
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`*,*::before,*::after{transition:none!important}`
|
||||
)
|
||||
`*,*::before,*::after{transition:none!important}`,
|
||||
),
|
||||
);
|
||||
document.head.appendChild(css);
|
||||
|
||||
@@ -202,8 +203,8 @@ export function ThemeProvider({
|
||||
}
|
||||
|
||||
// Apply attribute
|
||||
if (attribute === 'class') {
|
||||
root.classList.remove('light', 'dark');
|
||||
if (attribute === "class") {
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(newTheme);
|
||||
} else {
|
||||
root.setAttribute(attribute, newTheme);
|
||||
@@ -214,27 +215,27 @@ export function ThemeProvider({
|
||||
|
||||
setResolvedTheme(newTheme);
|
||||
},
|
||||
[attribute, disableTransitionOnChange]
|
||||
[attribute, disableTransitionOnChange],
|
||||
);
|
||||
|
||||
// Handle theme changes
|
||||
React.useEffect(() => {
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
applyTheme(resolved);
|
||||
}, [theme, applyTheme, getSystemTheme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
React.useEffect(() => {
|
||||
if (!enableSystem || theme !== 'system') return;
|
||||
if (!enableSystem || theme !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const handleChange = () => {
|
||||
applyTheme(getSystemTheme());
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme, enableSystem, applyTheme, getSystemTheme]);
|
||||
|
||||
// Persist to localStorage
|
||||
@@ -243,11 +244,11 @@ export function ThemeProvider({
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setThemeState(newTheme);
|
||||
},
|
||||
[storageKey]
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
|
||||
setTheme(resolvedTheme === "light" ? "dark" : "light");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
@@ -257,7 +258,7 @@ export function ThemeProvider({
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, toggleTheme]
|
||||
[theme, resolvedTheme, setTheme, toggleTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -270,7 +271,7 @@ export function ThemeProvider({
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -280,8 +281,8 @@ export function useTheme() {
|
||||
|
||||
```tsx
|
||||
// theme-toggle.tsx
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTheme } from './theme-provider';
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -289,27 +290,27 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
onClick={() => setTheme("light")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'light' ? 'bg-background shadow-sm' : ''
|
||||
theme === "light" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="Light theme"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
onClick={() => setTheme("dark")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'dark' ? 'bg-background shadow-sm' : ''
|
||||
theme === "dark" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="Dark theme"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
onClick={() => setTheme("system")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'system' ? 'bg-background shadow-sm' : ''
|
||||
theme === "system" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="System theme"
|
||||
>
|
||||
@@ -326,42 +327,42 @@ export function ThemeToggle() {
|
||||
|
||||
```css
|
||||
/* Brand A - Corporate Blue */
|
||||
[data-brand='corporate'] {
|
||||
[data-brand="corporate"] {
|
||||
--brand-primary: #0066cc;
|
||||
--brand-primary-hover: #0052a3;
|
||||
--brand-secondary: #f0f7ff;
|
||||
--brand-accent: #00a3e0;
|
||||
|
||||
--brand-font-heading: 'Helvetica Neue', sans-serif;
|
||||
--brand-font-body: 'Open Sans', sans-serif;
|
||||
--brand-font-heading: "Helvetica Neue", sans-serif;
|
||||
--brand-font-body: "Open Sans", sans-serif;
|
||||
|
||||
--brand-radius: 0.25rem;
|
||||
--brand-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Brand B - Modern Startup */
|
||||
[data-brand='startup'] {
|
||||
[data-brand="startup"] {
|
||||
--brand-primary: #7c3aed;
|
||||
--brand-primary-hover: #6d28d9;
|
||||
--brand-secondary: #faf5ff;
|
||||
--brand-accent: #f472b6;
|
||||
|
||||
--brand-font-heading: 'Poppins', sans-serif;
|
||||
--brand-font-body: 'Inter', sans-serif;
|
||||
--brand-font-heading: "Poppins", sans-serif;
|
||||
--brand-font-body: "Inter", sans-serif;
|
||||
|
||||
--brand-radius: 1rem;
|
||||
--brand-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Brand C - Minimal */
|
||||
[data-brand='minimal'] {
|
||||
[data-brand="minimal"] {
|
||||
--brand-primary: #171717;
|
||||
--brand-primary-hover: #404040;
|
||||
--brand-secondary: #fafafa;
|
||||
--brand-accent: #171717;
|
||||
|
||||
--brand-font-heading: 'Space Grotesk', sans-serif;
|
||||
--brand-font-body: 'IBM Plex Sans', sans-serif;
|
||||
--brand-font-heading: "Space Grotesk", sans-serif;
|
||||
--brand-font-body: "IBM Plex Sans", sans-serif;
|
||||
|
||||
--brand-radius: 0;
|
||||
--brand-shadow: none;
|
||||
@@ -402,7 +403,7 @@ export function ThemeToggle() {
|
||||
--color-accent: #0000ee;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--color-text: #ffffff;
|
||||
--color-text-muted: #ffffff;
|
||||
--color-bg: #000000;
|
||||
@@ -470,9 +471,9 @@ export default function RootLayout({ children }) {
|
||||
|
||||
```tsx
|
||||
// theme.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider, useTheme } from './theme-provider';
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ThemeProvider, useTheme } from "./theme-provider";
|
||||
|
||||
function TestComponent() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
@@ -480,34 +481,34 @@ function TestComponent() {
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||
<button onClick={() => setTheme("dark")}>Set Dark</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
it('should default to system theme', () => {
|
||||
describe("ThemeProvider", () => {
|
||||
it("should default to system theme", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('system');
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||
});
|
||||
|
||||
it('should switch to dark theme', async () => {
|
||||
it("should switch to dark theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Set Dark'));
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
await user.click(screen.getByText("Set Dark"));
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("dark");
|
||||
expect(document.documentElement).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user