Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion design-system/documentation/token-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ components to the design system.
| Borders and radius | `tokens/borders.json` | `--radius-*`, `--border-width-*`, `--border-default`, `--border-subtle`, `--border-emphasis`, `--border-interactive`, `--border-error`, `--border-success` | `Field`, `VaultCard`, `ConfirmationModal`, wallet dropdowns, pills, avatars, focus states |
| Shadows | `tokens/shadows.json` | Elevation references for raised surfaces and overlays | Modals, dropdowns, raised cards, dashboard surfaces |
| Motion | `tokens/motion.json` | `src/utils/motion.ts` exports `duration`, `ease`, `transitionEnter`, `transitionExit`, and `transitionPage` | `Notification`, animated overlays, dropdowns, page transitions |
| Z-index | `tokens/z-index.json` | `--z-index-base`, `--z-index-header`, `--z-index-drawer`, `--z-index-modal`, `--z-index-toast` | `Layout`, `MobileDrawer`, `ConfirmationModal`, wallet modals, notification popovers |
| Z-index | `tokens/z-index.json` | `--z-index-base`, `--z-index-header`, `--z-index-drawer`, `--z-index-modal`, `--z-index-toast` | `Layout`, `MobileDrawer`, `ConfirmationModal`, wallet modals, notification popovers |`n| Opacity | `tokens/opacity.json` | `--opacity-disabled`, `--opacity-backdrop`, `--opacity-hover`, `--opacity-muted` | `Field`, `Modal`, disabled controls, overlays |

## Component Notes

Expand Down
225 changes: 116 additions & 109 deletions design-system/src/__tests__/token-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,140 +25,147 @@ describe('token-loader', () => {
});

it('should throw if JSON is malformed', () => {
mockedFs.readFileSync.mockReturnValue('{"invalid": }');
expect(() => loadTokens('invalid.json')).toThrow();
mockedFs.readFileSync.mockReturnValue('{"invalid": }');
expect(() => loadTokens('invalid.json')).toThrow();
});
});

describe('getAllTokens', () => {
it('should merge all tokens', () => {
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('typography.json')) return '{"font": "sans"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
return '{}';
});

const allTokens = getAllTokens();
expect(allTokens).toEqual({
"color": "red",
"font": "sans",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px"
});
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('typography.json')) return '{"font": "sans"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
if (path.toString().includes('z-index.json')) return '{"zIndex": 100}';
if (path.toString().includes('opacity.json')) return '{"opacity": 0.5}';
return '{}';
});

const allTokens = getAllTokens();
expect(allTokens).toEqual({
"color": "red",
"font": "sans",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px",
"zIndex": 100,
"opacity": 0.5
});
});

it('should continue and warn if a file fails to load', () => {
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) throw new Error('File not found');
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
return '{}';
});

const allTokens = getAllTokens();

// The failed file is omitted, every other file is still merged.
expect(allTokens).toEqual({
"color": "red",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px"
});
expect(allTokens).not.toHaveProperty('font');
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) throw new Error('File not found');
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
if (path.toString().includes('z-index.json')) return '{"zIndex": 100}';
if (path.toString().includes('opacity.json')) return '{"opacity": 0.5}';
return '{}';
});

const allTokens = getAllTokens();

expect(allTokens).toEqual({
"color": "red",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px",
"zIndex": 100,
"opacity": 0.5
});
expect(allTokens).not.toHaveProperty('font');
});

it('should warn with the name of the file that failed to load', () => {
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) throw new Error('File not found');
return '{}';
});

getAllTokens();

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
'Failed to load typography.json:',
expect.any(Error)
);
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) throw new Error('File not found');
return '{}';
});

getAllTokens();

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
'Failed to load typography.json:',
expect.any(Error)
);
});

it('should continue and warn if a file has malformed JSON', () => {
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) return '{"invalid": }';
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
return '{}';
});

const allTokens = getAllTokens();

// A parse error is caught just like a read error: the bad file is dropped.
expect(allTokens).toEqual({
"color": "red",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px"
});
expect(console.warn).toHaveBeenCalledWith(
'Failed to load typography.json:',
expect.any(SyntaxError)
);
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('typography.json')) return '{"invalid": }';
if (path.toString().includes('colors.json')) return '{"color": "red"}';
if (path.toString().includes('spacing.json')) return '{"space": "4px"}';
if (path.toString().includes('shadows.json')) return '{"shadow": "1px"}';
if (path.toString().includes('motion.json')) return '{"motion": "ease"}';
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
if (path.toString().includes('z-index.json')) return '{"zIndex": 100}';
if (path.toString().includes('opacity.json')) return '{"opacity": 0.5}';
return '{}';
});

const allTokens = getAllTokens();

expect(allTokens).toEqual({
"color": "red",
"space": "4px",
"shadow": "1px",
"motion": "ease",
"border": "1px",
"zIndex": 100,
"opacity": 0.5
});
expect(console.warn).toHaveBeenCalledWith(
'Failed to load typography.json:',
expect.any(SyntaxError)
);
});

it('should let later files override earlier keys via Object.assign', () => {
// colors.json is merged first, borders.json last; the shared "token"
// key must reflect the last file processed.
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('colors.json')) return '{"token": "from-colors"}';
if (path.toString().includes('borders.json')) return '{"token": "from-borders"}';
return '{}';
});
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('colors.json')) return '{"token": "from-colors"}';
if (path.toString().includes('opacity.json')) return '{"token": "from-opacity"}';
return '{}';
});

const allTokens = getAllTokens();
const allTokens = getAllTokens();

expect(allTokens).toEqual({"token": "from-borders"});
expect(allTokens).toEqual({"token": "from-opacity"});
});

it('should still merge a file ordered after a failing one', () => {
mockedFs.readFileSync.mockImplementation((path) => {
// colors (first) fails, borders (last) must still be merged.
if (path.toString().includes('colors.json')) throw new Error('File not found');
if (path.toString().includes('borders.json')) return '{"border": "1px"}';
return '{}';
});

const allTokens = getAllTokens();

expect(allTokens).toEqual({"border": "1px"});
expect(console.warn).toHaveBeenCalledWith(
'Failed to load colors.json:',
expect.any(Error)
);
mockedFs.readFileSync.mockImplementation((path) => {
if (path.toString().includes('colors.json')) throw new Error('File not found');
if (path.toString().includes('opacity.json')) return '{"opacity": 0.5}';
return '{}';
});

const allTokens = getAllTokens();

expect(allTokens).toEqual({"opacity": 0.5});
expect(console.warn).toHaveBeenCalledWith(
'Failed to load colors.json:',
expect.any(Error)
);
});

it('should return an empty object and warn once per file when all files fail', () => {
mockedFs.readFileSync.mockImplementation(() => {
throw new Error('File not found');
});
mockedFs.readFileSync.mockImplementation(() => {
throw new Error('File not found');
});

const allTokens = getAllTokens();
const allTokens = getAllTokens();

expect(allTokens).toEqual({});
expect(console.warn).toHaveBeenCalledTimes(7);
expect(allTokens).toEqual({});
expect(console.warn).toHaveBeenCalledTimes(8);
});
});
});
});
14 changes: 14 additions & 0 deletions design-system/src/__tests__/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,17 @@ describe('z-index token layering scale', () => {
});
});
});

describe('opacity token scale', () => {
it('loads opacity tokens from opacity.json', () => {
const tokens = loadTokens('opacity.json');

expect(tokens).toHaveProperty('opacity');
expect(tokens.opacity).toMatchObject({
disabled: { $value: 0.5 },
backdrop: { $value: 0.5 },
hover: { $value: 0.08 },
muted: { $value: 0.72 },
});
});
});
2 changes: 1 addition & 1 deletion design-system/src/utils/token-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function loadTokens(tokenFile: string): DesignTokens {
}

export function getAllTokens(): DesignTokens {
const tokenFiles = ['colors.json', 'typography.json', 'spacing.json', 'shadows.json', 'motion.json', 'borders.json', 'z-index.json'];
const tokenFiles = ['colors.json', 'typography.json', 'spacing.json', 'shadows.json', 'motion.json', 'borders.json', 'z-index.json', 'opacity.json'];
const allTokens: DesignTokens = {};

tokenFiles.forEach(file => {
Expand Down
25 changes: 25 additions & 0 deletions design-system/tokens/opacity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://design-tokens.github.io/community-group/format.json",
"opacity": {
"disabled": {
"$type": "number",
"$value": 0.5,
"$description": "Disabled control opacity"
},
"backdrop": {
"$type": "number",
"$value": 0.5,
"$description": "Modal and overlay backdrop opacity"
},
"hover": {
"$type": "number",
"$value": 0.08,
"$description": "Subtle hover-state layer opacity"
},
"muted": {
"$type": "number",
"$value": 0.72,
"$description": "Muted content opacity when color alone is not enough"
}
}
}
6 changes: 5 additions & 1 deletion src/components/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface FieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '
}

export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
({ label, hint, error, id, required, ...props }, ref) => {
({ label, hint, error, id, required, disabled, style, ...props }, ref) => {
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`
const errorId = error ? `${fieldId}-error` : undefined
const hintId = hint && !error ? `${fieldId}-hint` : undefined
Expand All @@ -30,6 +30,7 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
ref={ref}
id={fieldId}
required={required}
disabled={disabled}
aria-label={label}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
Expand All @@ -40,6 +41,9 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
border: error ? '1px solid var(--danger)' : '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
opacity: disabled ? 'var(--opacity-disabled)' : undefined,
cursor: disabled ? 'not-allowed' : undefined,
...style,
}}
{...props}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function Modal({
children,
ariaLabelledBy,
ariaDescribedBy,
overlayClassName = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",
overlayClassName = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/[var(--opacity-backdrop)] backdrop-blur-sm",
contentClassName = "bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full overflow-hidden flex flex-col",
}: ModalProps) {
const shouldReduceMotion = useReducedMotion();
Expand Down
6 changes: 6 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@
--z-index-drawer: 200;
--z-index-modal: 300;
--z-index-toast: 400;

/* Opacity scale — mirrors design-system/tokens/opacity.json */
--opacity-disabled: 0.5;
--opacity-backdrop: 0.5;
--opacity-hover: 0.08;
--opacity-muted: 0.72;
}

/* Responsive breakpoints - update CSS variables */
Expand Down