Skip to content
Merged
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
133 changes: 133 additions & 0 deletions src/test/unit/ui/dark-surface-contrast.test.js
Original file line number Diff line number Diff line change
@@ -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('</Button>'));
expect(button).toMatch(/bg="yellow\.400"/);
expect(button).toMatch(/color="gray\.900"/);
expect(button).not.toMatch(/colorScheme="cyan"/);
});
});
5 changes: 4 additions & 1 deletion src/test/unit/ui/governance-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')");
});
Expand Down
15 changes: 11 additions & 4 deletions src/ui/app/components/inlineSignAction.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 15 additions & 5 deletions src/ui/app/components/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 {
Expand All @@ -244,13 +252,15 @@ 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);
}

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);
}

Expand Down
37 changes: 27 additions & 10 deletions src/ui/app/hooks/useSurfaceColors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
15 changes: 14 additions & 1 deletion src/ui/app/pages/governance.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ const Governance = () => {
inputBorder,
placeholder,
cyanLink,
disabledBg,
disabledFg,
} = useSurfaceColors();

const sortedProposals = React.useMemo(() => {
Expand Down Expand Up @@ -785,8 +787,19 @@ const Governance = () => {
/>
<Button
data-testid="governance-custom-drep-delegate"
colorScheme="cyan"
colorScheme="yellow"
bg="yellow.400"
color="gray.900"
fontWeight="bold"
px={8}
_hover={{ bg: 'yellow.300' }}
_active={{ bg: 'yellow.500' }}
_disabled={{
bg: disabledBg,
color: disabledFg,
cursor: 'not-allowed',
opacity: 1,
}}
onClick={() => void handleCustomDrepDelegation()}
isDisabled={!drepIdInput.trim()}
isLoading={isBuildingTx}
Expand Down
28 changes: 24 additions & 4 deletions src/ui/app/pages/send.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,17 @@ export const sendStore = {
const Send = () => {
const isMounted = useIsMounted();
const settings = useStoreState((state) => state.settings.settings);
const { pageBg, pageFg, mutedFg, subtleFg, inputBg } =
useSurfaceColors();
const {
pageBg,
pageFg,
mutedFg,
subtleFg,
inputBg,
inputBorder,
cardHoverBg,
disabledBg,
disabledFg,
} = useSurfaceColors();
const [address, setAddress] = [
useStoreState((state) => state.globalModel.sendStore.address),
useStoreActions((actions) => actions.globalModel.sendStore.setAddress),
Expand Down Expand Up @@ -1133,6 +1142,8 @@ const Send = () => {
}}
variant="filled"
bg={inputBg}
borderWidth="1px"
borderColor={inputBorder}
isDisabled={isLoading || value.sendAll}
isInvalid={amountTooSmall || amountTooLarge}
onFocus={() => (focus.current = true)}
Expand Down Expand Up @@ -1167,6 +1178,9 @@ const Send = () => {
size="xs"
variant="outline"
rounded="full"
borderColor={inputBorder}
color={pageFg}
_hover={{ bg: cardHoverBg }}
isDisabled={isLoading || availableLovelace <= 0n}
onClick={() => applyAdaShare(chip.n, chip.d)}
>
Expand Down Expand Up @@ -1319,6 +1333,8 @@ const Send = () => {
}}
variant="filled"
bg={inputBg}
borderWidth="1px"
borderColor={inputBorder}
rounded="xl"
placeholder="Optional message (on-chain metadata)"
fontSize="sm"
Expand Down Expand Up @@ -1489,8 +1505,8 @@ const Send = () => {
}}
_active={{ bg: 'yellow.500' }}
_disabled={{
bg: 'whiteAlpha.200',
color: 'whiteAlpha.500',
bg: disabledBg,
color: disabledFg,
cursor: 'not-allowed',
transform: 'none',
opacity: 1,
Expand Down Expand Up @@ -1790,6 +1806,7 @@ const AddressPopup = ({
isLoading,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { inputBg, inputBorder } = useSurfaceColors();
const checkColor = useColorModeValue('yellow.500', 'yellow.200');
const ref = React.useRef(false);
const [state, setState] = React.useState({
Expand Down Expand Up @@ -1891,6 +1908,9 @@ const AddressPopup = ({
spellCheck={false}
rounded="xl"
h="48px"
bg={inputBg}
borderWidth="1px"
borderColor={inputBorder}
onBlur={async (e) => {
await new Promise((res, rej) => setTimeout(() => res()));
if (ref.current) {
Expand Down