From a05d264b33f2516867a186a22f21fa3f9977087f Mon Sep 17 00:00:00 2001 From: dhanz Date: Fri, 4 Sep 2026 19:35:47 +0000 Subject: [PATCH] Lift dark surfaces on Send, Delegate and Vote These pages stacked translucent black on a near-black page, so nested cards and inputs sank into their parent instead of rising above it and every panel faded into the background toward its bottom edge. Elevation now goes lighter, not darker: - insetBg/cardBg become white overlays so status cards read as raised. - Panels carry a hairline edge (inset ring, so no 1px reflow) and a nearly flat fill instead of a gradient that faded out at 0.015 alpha. Measured on the Send page: panel edge luminance 19 -> 69, and panel interior holds 27 -> 25 top to bottom where it used to drop 18 -> 13. - panelBorder is a hairline in both modes rather than transparent. - Fields keep a modest fill and lean on a visible rim, so an input reads as an input whether it sits on a panel or inside a nested card. - Secondary text and placeholders move off the dim floor. Disabled CTAs were the worst offender: whiteAlpha.200 fill under whiteAlpha.500 text measured 3.26:1, so "Review transaction" was effectively invisible. Shared disabledBg/disabledFg tokens take it to 6.96:1 on the same screenshot. Governance also shipped a solid cyan primary while Send and Delegate used brand yellow, so the same commit action looked like a different control per page; it now matches. Cyan stays on badges and outline buttons as the section accent. --- .../unit/ui/dark-surface-contrast.test.js | 133 ++++++++++++++++++ src/test/unit/ui/governance-page.test.js | 5 +- src/ui/app/components/inlineSignAction.jsx | 15 +- src/ui/app/components/styles.css | 20 ++- src/ui/app/hooks/useSurfaceColors.js | 37 +++-- src/ui/app/pages/governance.jsx | 15 +- src/ui/app/pages/send.jsx | 28 +++- 7 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 src/test/unit/ui/dark-surface-contrast.test.js 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 = () => { />