diff --git a/design-system/documentation/token-catalog.md b/design-system/documentation/token-catalog.md index 5aa761e..9316401 100644 --- a/design-system/documentation/token-catalog.md +++ b/design-system/documentation/token-catalog.md @@ -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 diff --git a/design-system/src/__tests__/token-loader.test.ts b/design-system/src/__tests__/token-loader.test.ts index b67e0dd..d9ed795 100644 --- a/design-system/src/__tests__/token-loader.test.ts +++ b/design-system/src/__tests__/token-loader.test.ts @@ -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); }); }); -}); +}); \ No newline at end of file diff --git a/design-system/src/__tests__/tokens.test.ts b/design-system/src/__tests__/tokens.test.ts index 8c898d6..9210cbe 100644 --- a/design-system/src/__tests__/tokens.test.ts +++ b/design-system/src/__tests__/tokens.test.ts @@ -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 }, + }); + }); +}); \ No newline at end of file diff --git a/design-system/src/utils/token-loader.ts b/design-system/src/utils/token-loader.ts index 8c2f4b0..7b49b1e 100644 --- a/design-system/src/utils/token-loader.ts +++ b/design-system/src/utils/token-loader.ts @@ -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 => { diff --git a/design-system/tokens/opacity.json b/design-system/tokens/opacity.json new file mode 100644 index 0000000..cb72e51 --- /dev/null +++ b/design-system/tokens/opacity.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/components/Field.tsx b/src/components/Field.tsx index 6358cba..d347e48 100644 --- a/src/components/Field.tsx +++ b/src/components/Field.tsx @@ -8,7 +8,7 @@ interface FieldProps extends Omit, ' } export const Field = React.forwardRef( - ({ 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 @@ -30,6 +30,7 @@ export const Field = React.forwardRef( ref={ref} id={fieldId} required={required} + disabled={disabled} aria-label={label} aria-describedby={describedBy} aria-invalid={error ? 'true' : undefined} @@ -40,6 +41,9 @@ export const Field = React.forwardRef( 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} /> diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index f018c25..d3b1b9c 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -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(); diff --git a/src/index.css b/src/index.css index 76707f8..384ec8d 100644 --- a/src/index.css +++ b/src/index.css @@ -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 */