diff --git a/src/test/unit/ui/dark-surface-contrast.test.js b/src/test/unit/ui/dark-surface-contrast.test.js new file mode 100644 index 00000000..91b3daa8 --- /dev/null +++ b/src/test/unit/ui/dark-surface-contrast.test.js @@ -0,0 +1,133 @@ +/** + * Legibility contract for the shared surface tokens used by Send, Delegate and Vote. + * + * The pages were reported as "dark on dark": nested cards and inputs were painted + * with translucent *black* over a near-black page, so every surface sank into its + * parent instead of rising above it, and disabled CTAs were unreadable. + */ +jest.mock('@chakra-ui/react', () => ({ + useColorModeValue: (light, dark) => ({ __light: light, __dark: dark }), +})); + +// Aliased away from the `use*` name on purpose: with useColorModeValue mocked to +// a plain function this is just a token map, not a hook call in a component. +const surfaceTokens = + require('../../../ui/app/hooks/useSurfaceColors').default; +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const read = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); + +const dark = (key) => surfaceTokens()[key].__dark; +const light = (key) => surfaceTokens()[key].__light; + +/** Alpha of an `rgba(...)` string, or null when the token is not rgba. */ +const rgbaAlpha = (value) => { + const match = /^rgba\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)[\s,]+([\d.]+)\s*\)$/.exec( + value + ); + return match ? { r: +match[1], a: parseFloat(match[4]) } : null; +}; + +/** Numeric weight of a Chakra whiteAlpha.N / blackAlpha.N token. */ +const alphaToken = (value) => { + const match = /^(white|black)Alpha\.(\d+)$/.exec(value); + return match ? +match[2] : null; +}; + +describe('dark surface tokens keep stacked surfaces readable', () => { + it('elevates nested surfaces with white overlays, never black ones', () => { + // A black overlay on a #080808 page is darker than the panel it sits in, + // which is what made status cards and inputs read as holes. + for (const key of ['insetBg', 'cardBg', 'cardHoverBg', 'poolIdleBg']) { + const rgba = rgbaAlpha(dark(key)); + expect(rgba).not.toBeNull(); + expect({ key, r: rgba.r }).toEqual({ key, r: 255 }); + expect(rgba.a).toBeGreaterThan(0); + } + }); + + it('draws a hairline edge on panels in both color modes', () => { + // Soft shadows alone left panel edges invisible against a near-black page. + expect(dark('panelBorder')).not.toBe('transparent'); + expect(light('panelBorder')).not.toBe('transparent'); + expect(rgbaAlpha(dark('panelBorder')).a).toBeGreaterThan(0); + }); + + it('keeps disabled CTA labels readable against their own background', () => { + const bg = alphaToken(dark('disabledBg')); + const fg = alphaToken(dark('disabledFg')); + expect(bg).not.toBeNull(); + expect(fg).not.toBeNull(); + // Old pairing was bg 200 / text 500, roughly 2:1 contrast. + expect(fg).toBeGreaterThanOrEqual(700); + expect(fg - bg).toBeGreaterThanOrEqual(400); + }); + + it('holds secondary text and placeholders above the old dim floor', () => { + expect(alphaToken(dark('mutedFg'))).toBeGreaterThanOrEqual(800); + expect(alphaToken(dark('subtleFg'))).toBeGreaterThanOrEqual(700); + expect(alphaToken(dark('placeholder'))).toBeGreaterThanOrEqual(600); + expect(alphaToken(dark('inputBorder'))).toBeGreaterThanOrEqual(200); + }); +}); + +describe('panel surface CSS', () => { + const css = read('src/ui/app/components/styles.css'); + const block = (selector) => { + const start = css.indexOf(selector + ' {'); + expect(start).toBeGreaterThan(-1); + return css.slice(start, css.indexOf('}', start)); + }; + + it('rings .lucem-inset-surface with an inset hairline in both modes', () => { + // An inset ring instead of a real border: no 1px reflow on panels that + // declare `border: none`. + expect(block('.lucem-inset-surface')).toMatch( + /inset 0 0 0 1px rgba\(255, 255, 255/ + ); + expect(block("html[data-theme='light'] .lucem-inset-surface")).toMatch( + /inset 0 0 0 1px rgba\(15, 23, 42/ + ); + }); + + it('does not fade the bottom of a panel back into the page', () => { + const gradient = block('.lucem-inset-surface'); + const stops = [...gradient.matchAll(/rgba\(255, 255, 255, ([\d.]+)\)/g)] + .map((m) => parseFloat(m[1])) + .filter((a) => a < 0.1); + expect(stops.length).toBeGreaterThan(0); + expect(Math.min(...stops)).toBeGreaterThanOrEqual(0.04); + }); +}); + +describe('Send, Delegate and Vote share one action language', () => { + const pages = { + 'send.jsx': read('src/ui/app/pages/send.jsx'), + 'staking.jsx': read('src/ui/app/pages/staking.jsx'), + 'governance.jsx': read('src/ui/app/pages/governance.jsx'), + 'inlineSignAction.jsx': read('src/ui/app/components/inlineSignAction.jsx'), + }; + + it('never reintroduces the unreadable disabled pairing', () => { + for (const [name, source] of Object.entries(pages)) { + expect({ name, hit: /bg: 'whiteAlpha\.200',\s*\n\s*color: 'whiteAlpha\.500'/.test(source) }).toEqual({ + name, + hit: false, + }); + } + }); + + it('commits every flow with the same yellow primary button', () => { + // Governance shipped a solid cyan CTA while Send and Delegate used brand + // yellow, so the same action looked like a different control per page. + const cta = pages['governance.jsx'].slice( + pages['governance.jsx'].indexOf('governance-custom-drep-delegate') + ); + const button = cta.slice(0, cta.indexOf('')); + expect(button).toMatch(/bg="yellow\.400"/); + expect(button).toMatch(/color="gray\.900"/); + expect(button).not.toMatch(/colorScheme="cyan"/); + }); +}); diff --git a/src/test/unit/ui/governance-page.test.js b/src/test/unit/ui/governance-page.test.js index 52e73e42..bd251bdb 100644 --- a/src/test/unit/ui/governance-page.test.js +++ b/src/test/unit/ui/governance-page.test.js @@ -158,7 +158,10 @@ describe('staking and governance theme surfaces', () => { ); expect(hookSrc).toContain("useColorModeValue('#f4f6fb', '#080808')"); expect(hookSrc).toContain('panelShadow'); - expect(hookSrc).toContain("useColorModeValue('transparent', 'transparent')"); + // panelBorder used to be transparent in both modes, which left cards with no + // edge at all against a near-black page. See dark-surface-contrast.test.js. + expect(hookSrc).toContain('panelBorder'); + expect(hookSrc).not.toContain("useColorModeValue('transparent', 'transparent')"); expect(hookSrc).toContain('cyanLink'); expect(hookSrc).toContain("useColorModeValue('cyan.600', 'cyan.300')"); }); diff --git a/src/ui/app/components/inlineSignAction.jsx b/src/ui/app/components/inlineSignAction.jsx index b493cc1e..9940a320 100644 --- a/src/ui/app/components/inlineSignAction.jsx +++ b/src/ui/app/components/inlineSignAction.jsx @@ -30,8 +30,15 @@ const InlineSignAction = ({ onHwRequest, onCancel, }) => { - const { pageFg, mutedFg, inputBg, inputBorder, placeholder } = - useSurfaceColors(); + const { + pageFg, + mutedFg, + inputBg, + inputBorder, + placeholder, + disabledBg, + disabledFg, + } = useSurfaceColors(); const [password, setPassword] = React.useState(''); const [show, setShow] = React.useState(false); const [busy, setBusy] = React.useState(false); @@ -126,8 +133,8 @@ const InlineSignAction = ({ _hover={{ bg: 'yellow.300', transform: 'translateY(-1px)' }} _active={{ bg: 'yellow.500' }} _disabled={{ - bg: 'whiteAlpha.200', - color: 'whiteAlpha.500', + bg: disabledBg, + color: disabledFg, cursor: 'not-allowed', transform: 'none', opacity: 1, diff --git a/src/ui/app/components/styles.css b/src/ui/app/components/styles.css index 2eac4311..7b925203 100644 --- a/src/ui/app/components/styles.css +++ b/src/ui/app/components/styles.css @@ -210,16 +210,23 @@ html[data-theme='light'] .lucem-settings-shell { border: none; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); + /* Keep the fill nearly flat: the old 0.07 -> 0.015 falloff made the bottom of + every panel fade back into the page, so cards read as edgeless smears. */ background: linear-gradient( 165deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.03) 42%, - rgba(255, 255, 255, 0.015) 100% + rgba(255, 255, 255, 0.09) 0%, + rgba(255, 255, 255, 0.075) 45%, + rgba(255, 255, 255, 0.07) 100% ); + /* Lucem's home screen defines controls with lit outlines on black rather than + grey fills, so panels here are separated by an edge instead of being washed + toward grey. The 0 0 0 1px inset ring draws that edge without the 1px reflow + a real border would cause on panels that declare `border: none`. */ box-shadow: 0 22px 56px rgba(0, 0, 0, 0.55), 0 2px 12px rgba(0, 0, 0, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.14); transition: box-shadow 0.55s cubic-bezier(0.22, 1, 0.36, 1), transform 0.55s cubic-bezier(0.22, 1, 0.36, 1), @@ -231,7 +238,8 @@ html[data-theme='light'] .lucem-settings-shell { box-shadow: 0 28px 64px rgba(0, 0, 0, 0.62), 0 4px 16px rgba(0, 0, 0, 0.4), - inset 0 1px 0 rgba(255, 255, 255, 0.09); + inset 0 0 0 1px rgba(255, 255, 255, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.2); } html[data-theme='light'] .lucem-inset-surface { @@ -244,6 +252,7 @@ html[data-theme='light'] .lucem-inset-surface { box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08), 0 2px 10px rgba(15, 23, 42, 0.04), + inset 0 0 0 1px rgba(15, 23, 42, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.95); } @@ -251,6 +260,7 @@ html[data-theme='light'] .lucem-inset-surface:hover { box-shadow: 0 24px 56px rgba(15, 23, 42, 0.11), 0 4px 14px rgba(15, 23, 42, 0.06), + inset 0 0 0 1px rgba(15, 23, 42, 0.1), inset 0 1px 0 rgba(255, 255, 255, 1); } diff --git a/src/ui/app/hooks/useSurfaceColors.js b/src/ui/app/hooks/useSurfaceColors.js index db7e3be4..cfdc18ea 100644 --- a/src/ui/app/hooks/useSurfaceColors.js +++ b/src/ui/app/hooks/useSurfaceColors.js @@ -3,28 +3,45 @@ import { useColorModeValue } from '@chakra-ui/react'; /** * Shared light/dark surface tokens for full-page flows (accounts, staking, governance). * Avoid Lucem gray.100/900 for page chrome — those mid-tones read too dark in light - * mode and too light in dark mode. Panels use soft elevation (no hard window borders). + * mode and too light in dark mode. + * + * Elevation rule: in dark mode a surface that sits on top of another surface is + * *lighter* than it, never darker. Stacking translucent black (the old insetBg) + * on a near-black page made nested cards and inputs read as holes, so panels and + * their contents dissolved into one another. */ export default function useSurfaceColors() { return { pageBg: useColorModeValue('#f4f6fb', '#080808'), pageFg: useColorModeValue('gray.900', 'white'), panelBg: useColorModeValue('rgba(255, 255, 255, 0.92)', '#121212'), - panelBorder: useColorModeValue('transparent', 'transparent'), + panelBorder: useColorModeValue( + 'rgba(15, 23, 42, 0.09)', + 'rgba(255, 255, 255, 0.14)' + ), panelShadow: useColorModeValue( '0 18px 48px rgba(15, 23, 42, 0.08), 0 2px 10px rgba(15, 23, 42, 0.04)', '0 22px 56px rgba(0, 0, 0, 0.55), 0 2px 12px rgba(0, 0, 0, 0.35)' ), cardBg: useColorModeValue('rgba(15, 23, 42, 0.04)', 'rgba(255, 255, 255, 0.04)'), cardHoverBg: useColorModeValue('rgba(15, 23, 42, 0.08)', 'rgba(255, 255, 255, 0.08)'), - insetBg: useColorModeValue('rgba(15, 23, 42, 0.05)', 'rgba(0, 0, 0, 0.35)'), - mutedFg: useColorModeValue('gray.600', 'whiteAlpha.700'), - subtleFg: useColorModeValue('gray.500', 'whiteAlpha.600'), - softFg: useColorModeValue('gray.700', 'whiteAlpha.800'), - ghostColor: useColorModeValue('gray.700', 'whiteAlpha.800'), - inputBg: useColorModeValue('white', '#1a1a1a'), - inputBorder: useColorModeValue('blackAlpha.100', 'whiteAlpha.100'), - placeholder: useColorModeValue('gray.500', 'whiteAlpha.500'), + insetBg: useColorModeValue( + 'rgba(15, 23, 42, 0.05)', + 'rgba(255, 255, 255, 0.055)' + ), + mutedFg: useColorModeValue('gray.600', 'whiteAlpha.800'), + subtleFg: useColorModeValue('gray.500', 'whiteAlpha.700'), + softFg: useColorModeValue('gray.700', 'whiteAlpha.900'), + ghostColor: useColorModeValue('gray.700', 'whiteAlpha.900'), + // Fields keep a modest fill and lean on a visible rim instead: the same input + // has to read as an input whether it sits on a panel or inside a nested card. + inputBg: useColorModeValue('white', '#1c1c1c'), + inputBorder: useColorModeValue('blackAlpha.300', 'whiteAlpha.400'), + placeholder: useColorModeValue('gray.500', 'whiteAlpha.600'), + // Disabled CTAs still have to be readable: whiteAlpha.200 on whiteAlpha.500 + // text left labels like "Review transaction" almost invisible. + disabledBg: useColorModeValue('blackAlpha.100', 'whiteAlpha.300'), + disabledFg: useColorModeValue('gray.500', 'whiteAlpha.700'), accentLink: useColorModeValue('blue.600', 'blue.200'), yellowLink: useColorModeValue('yellow.700', 'yellow.200'), cyanLink: useColorModeValue('cyan.600', 'cyan.300'), diff --git a/src/ui/app/pages/governance.jsx b/src/ui/app/pages/governance.jsx index 76e91683..75f06fd1 100644 --- a/src/ui/app/pages/governance.jsx +++ b/src/ui/app/pages/governance.jsx @@ -306,6 +306,8 @@ const Governance = () => { inputBorder, placeholder, cyanLink, + disabledBg, + disabledFg, } = useSurfaceColors(); const sortedProposals = React.useMemo(() => { @@ -785,8 +787,19 @@ const Governance = () => { />