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
1 change: 1 addition & 0 deletions apps/mobile/scripts/generate-theme-colors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const TOKEN_KEYS = [
['foreground', '--foreground'],
['primary', '--primary'],
['primaryForeground', '--primary-foreground'],
['primaryDisabled', '--primary-disabled'],
['secondary', '--secondary'],
['secondaryForeground', '--secondary-foreground'],
['muted', '--muted'],
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/markdown-palette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const colors = {
foreground: '#14130F',
primary: '#4F5A10',
primaryForeground: '#FFFFFF',
primaryDisabled: '#6F6A61',
secondary: '#F0EEE6',
secondaryForeground: '#14130F',
muted: '#F0EEE6',
Expand Down
21 changes: 11 additions & 10 deletions apps/mobile/src/components/agents/new-session-start-button.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { ActivityIndicator } from '@/components/ui/activity-indicator';
import { useTranslation } from 'react-i18next';

import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

type NewSessionStartButtonProps = {
isCloneEntry: boolean;
Expand All @@ -16,7 +14,9 @@ type NewSessionStartButtonProps = {
/**
* The new-session Start submit button. The Continue form shows a busy label
* (import for a live CLI, clone for Cloud Agent) and keeps the visible child;
* the ordinary form swaps in a spinner.
* the ordinary form swaps in the Button's own busy spinner. Both pass
* `loading` so the busy state keeps the brand fill instead of the muted
* disabled fill.
*/
export function NewSessionStartButton({
isCloneEntry,
Expand All @@ -25,7 +25,6 @@ export function NewSessionStartButton({
isStarting,
onStartSession,
}: Readonly<NewSessionStartButtonProps>) {
const colors = useThemeColors();
const { t } = useTranslation();

if (isCloneEntry) {
Expand All @@ -49,12 +48,14 @@ export function NewSessionStartButton({
}

return (
<Button size="lg" className="mt-6" disabled={isStartDisabled} onPress={onStartSession}>
{isStarting ? (
<ActivityIndicator size="small" color={colors.primaryForeground} />
) : (
<Text>{t('agentChat.newSession.startSession')}</Text>
)}
<Button
size="lg"
className="mt-6"
disabled={isStartDisabled}
loading={isStarting}
onPress={onStartSession}
>
{isStarting ? null : <Text>{t('agentChat.newSession.startSession')}</Text>}
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ describe('BootstrapErrorScreen primaryLoading', () => {
const primary = findPressableByAccessibilityLabel(renderer.root, 'Retry loading account');
expect(primary.props.disabled).toBe(true);
expect(primary.props.accessibilityState).toEqual({ disabled: true, busy: true });
expect(primary.props.className).toContain('opacity-50');
// A busy primary keeps its brand fill: dimming the whole control would
// composite the ink label into the washed fill (about 2.4:1 in light).
expect(primary.props.className).toContain('bg-primary');
expect(primary.props.className).not.toContain('opacity-50');
// The busy indicator is the button's inline spinner; the label stays.
expect(
renderer.root.findAll(
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/src/components/ui/button.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,47 @@ describe('Button native target contract', () => {
expect(button.findByType(NativeText).children).toEqual(['Retry']);
});

it('keeps a disabled primary label legible instead of dimming the brand pair', () => {
const button = renderButton({ disabled: true });
const classes = (button.props.className as string).split(' ');
// bg-primary-disabled must win over the variant's bg-primary so the disabled
// fill is the muted surface the ink label stays readable on.
expect(classes).toContain('bg-primary-disabled');
expect(classes).not.toContain('bg-primary');
expect(classes).not.toContain('opacity-50');
// The label keeps the ink colour; the disabled fill is chosen to contrast
// with it, so hard-coded primaryForeground children stay legible too.
const label = button.findByType(NativeText).props.className as string;
expect(label).toContain('text-primary-foreground');
expect(label).not.toContain('text-muted-foreground');
});

it('keeps a hard-coded primaryForeground child on a fill that contrasts with it', () => {
const button = renderButton({
disabled: true,
children: createElement(Text, { className: 'text-primary-foreground' }, 'Save'),
});
const classes = (button.props.className as string).split(' ');
expect(classes).toContain('bg-primary-disabled');
expect(classes).not.toContain('bg-muted');
expect(button.findByType(NativeText).props.className).toContain('text-primary-foreground');
});

it('keeps a busy primary on its brand fill so it still reads as working', () => {
const button = renderButton({ loading: true });
const classes = (button.props.className as string).split(' ');
expect(classes).toContain('bg-primary');
expect(classes).not.toContain('bg-primary-disabled');
expect(classes).not.toContain('opacity-50');
});

it('keeps the dimmed treatment for a disabled non-primary variant', () => {
const button = renderButton({ variant: 'outline', disabled: true });
const classes = (button.props.className as string).split(' ');
expect(classes).toContain('opacity-50');
expect(classes).not.toContain('bg-primary-disabled');
});

it('keeps an enabled action connected to its visible outcome', () => {
function Action() {
const [started, setStarted] = useState(false);
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,25 @@ function Button({
}: ButtonProps) {
const colors = useThemeColors();
const isDisabled = Boolean(disabled) || Boolean(loading);
const isPrimary = (variant ?? 'default') === 'default';
// A default-variant fill is a saturated brand colour with a contrasting ink
// label. Halving the whole control's opacity for the disabled state
// composites that pair into olive-on-olive (about 2.4:1 in light, 4.4:1 in
// dark), and it also washes out any child that hard-codes primaryForeground.
// A disabled (not busy) primary instead takes a muted fill that still
// contrasts with the ink label, so the label stays legible. A busy primary
// keeps the brand fill and its spinner so it still reads as working.
const isMutedDisabled = isDisabled && !loading && isPrimary;
const isDimmed = isDisabled && !isPrimary;
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(isDisabled && 'opacity-50', buttonVariants({ variant, size }), className)}
className={cn(
isDimmed && 'opacity-50',
buttonVariants({ variant, size }),
isMutedDisabled && 'bg-primary-disabled',
className
)}
role="button"
disabled={isDisabled}
accessibilityState={{ ...accessibilityState, disabled: isDisabled, busy: loading }}
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
--accent-foreground: #ffffff;
--primary: #4f5a10;
--primary-foreground: #ffffff;
/* Disabled primary fill. The palette's warm neutral, chosen because it keeps
* the ink label (primary-foreground) legible: white on this fill clears 4.5:1,
* so a disabled default button reads as inactive without losing its label. */
--primary-disabled: #6f6a61;
--destructive: #b0483a;
--destructive-foreground: #ffffff;
--border: rgba(20, 15, 10, 0.09);
Expand Down Expand Up @@ -95,6 +99,8 @@
--accent-foreground: #1a1a10;
--primary: #e8f27a;
--primary-foreground: #1a1a10;
/* Dark counterpart of the disabled primary fill; near-black ink clears 4.5:1. */
--primary-disabled: #8a8680;
--destructive: #f28b7a;
--destructive-foreground: #1a1a10;
--border: rgba(255, 255, 255, 0.07);
Expand Down Expand Up @@ -155,6 +161,7 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-disabled: var(--primary-disabled);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/hooks/theme-colors.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const lightColors = {
foreground: '#14130F',
primary: '#4F5A10',
primaryForeground: '#FFFFFF',
primaryDisabled: '#6F6A61',
secondary: '#F0EEE6',
secondaryForeground: '#14130F',
muted: '#F0EEE6',
Expand Down Expand Up @@ -35,6 +36,7 @@ export const darkColors = {
foreground: '#F2F0EB',
primary: '#E8F27A',
primaryForeground: '#1A1A10',
primaryDisabled: '#8A8680',
secondary: '#1F1F24',
secondaryForeground: '#F2F0EB',
muted: '#1F1F24',
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/lib/hooks/use-theme-colors.contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ describe('light diff token contrast on tinted surfaces (WCAG AA text)', () => {
});
});

describe('disabled primary fill contrast (WCAG AA text)', () => {
// A disabled default-variant Button paints primaryForeground on
// `--primary-disabled`; the pair must clear 4.5:1 in both themes so the
// label (and any child that hard-codes primaryForeground) stays legible.
it('light theme: primaryForeground vs primaryDisabled >= 4.5:1', () => {
const ratio = contrastRatio(lightColors.primaryForeground, lightColors.primaryDisabled);
expect(ratio).toBeGreaterThanOrEqual(MIN_TEXT_RATIO);
});

it('dark theme: primaryForeground vs primaryDisabled >= 4.5:1', () => {
const ratio = contrastRatio(darkColors.primaryForeground, darkColors.primaryDisabled);
expect(ratio).toBeGreaterThanOrEqual(MIN_TEXT_RATIO);
});
});

describe('warn foreground token contrast (WCAG AA text)', () => {
it('light theme: warnForeground vs warn >= 4.5:1', () => {
// Precomputed ≈ 5.30:1 for #FFFFFF on #956011.
Expand Down
Loading