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
6 changes: 6 additions & 0 deletions apps/mobile/scripts/generate-theme-colors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export const TOKEN_KEYS = [
['agentKilocode', '--agent-kilocode'],
['agentCoral', '--agent-coral'],
['agentSky', '--agent-sky'],
['rowHoney', '--row-honey'],
['rowGold', '--row-gold'],
['rowLime', '--row-lime'],
['rowSage', '--row-sage'],
['rowMoss', '--row-moss'],
['rowFern', '--row-fern'],
];

function collectDeclarations(rule) {
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export default function TabsLayout() {
screenOptions={{
headerShown: false,
freezeOnBlur: true,
// The row palette colours a destination inside a screen; a tab tint
// says which tab is selected — a state, not a destination. One accent
// per tab would put six accents on the bar and fight the rows below
// it, and an icon-only recolour would disagree with `TabBarLabel`,
// which draws its label from its own `text-foreground` /
// `text-muted-foreground` classes and ignores the navigator tint.
tabBarActiveTintColor: colors.foreground,
tabBarInactiveTintColor: colors.mutedForeground,
tabBarBackground: TabBarBackground,
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/components/agents/markdown-palette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const colors = {
agentKilocode: '#B27214',
agentCoral: '#C25647',
agentSky: '#2C7FB0',
rowHoney: '#7C6C1D',
rowGold: '#7A7C1D',
rowLime: '#697C1D',
rowSage: '#587C1D',
rowMoss: '#467C1D',
rowFern: '#357C1D',
} satisfies ThemeColors;

describe('markdown palette', () => {
Expand Down
50 changes: 47 additions & 3 deletions apps/mobile/src/components/profile-action-tile.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { act, TestRenderer } from '@/test/renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { MessageSquare, SlidersHorizontal } from '@/components/ui/icons';
import { agentColor, toneColor } from '@/lib/agent-color';
import { agentColor, rowTint, toneColor } from '@/lib/agent-color';

import { ActionTile } from './profile-action-tile';
import { ConfigureRow } from './ui/configure-row';
Expand All @@ -22,6 +22,13 @@ const themeColors = vi.hoisted(() => {
warn: '#warn',
destructive: '#destructive',
mutedForeground: '#muted-foreground',
secondaryForeground: '#secondary-foreground',
rowHoney: '#row-honey',
rowGold: '#row-gold',
rowLime: '#row-lime',
rowSage: '#row-sage',
rowMoss: '#row-moss',
rowFern: '#row-fern',
};
return colors;
});
Expand Down Expand Up @@ -82,11 +89,12 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
hue: 'fern',
onPress: () => undefined,
})
);
const className = tileClassName(root);
const tint = agentColor('Feedback');
const tint = rowTint('fern');
expect(className).toContain('h-[30px]');
expect(className).toContain('w-[30px]');
expect(className).toContain('rounded-lg');
Expand All @@ -100,19 +108,53 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
hue: 'fern',
onPress: () => undefined,
})
);
const icon = iconNode(root, 'MessageSquare');
expect(icon.props.size).toBe(16);
expect(icon.props.color).toBe(themeColors[agentColor('Feedback').hueThemeKey]);
expect(icon.props.color).toBe(themeColors[rowTint('fern').hueThemeKey]);
});

it('renders the same tint for the same row in English and Serbian', () => {
const label = 'Feedback';
const english = render(
createElement(ActionTile, {
icon: MessageSquare,
label,
hue: 'fern',
onPress: () => undefined,
})
);
const englishClassName = tileClassName(english);
const englishColor = iconNode(english, 'MessageSquare').props.color as string;

const serbian = render(
createElement(ActionTile, {
icon: MessageSquare,
label: 'Povratne informacije',
hue: 'fern',
onPress: () => undefined,
})
);
const serbianClassName = tileClassName(serbian);
const serbianColor = iconNode(serbian, 'MessageSquare').props.color as string;

// The colour is chosen by the destination, never hashed from the label:
// the same row renders the same tile in every language.
expect(serbianClassName).toBe(englishClassName);
expect(serbianColor).toBe(englishColor);
expect(englishColor).not.toBe(themeColors.secondaryForeground);
expect(englishColor).not.toBe(themeColors[agentColor(label).hueThemeKey]);
});

it('tints the destructive row tile and keeps the red label', () => {
const root = render(
createElement(ActionTile, {
icon: MessageSquare,
label: 'Delete Account',
hue: 'fern',
destructive: true,
onPress: () => undefined,
})
Expand All @@ -133,6 +175,7 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
hue: 'fern',
onPress: () => undefined,
})
);
Expand Down Expand Up @@ -161,6 +204,7 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
hue: 'fern',
destructive: true,
onPress: () => undefined,
})
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/components/profile-action-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ import { Pressable } from 'react-native';
import { IconTile } from '@/components/ui/icon-tile';
import { Text } from '@/components/ui/text';
import { type LucideIcon } from '@/components/ui/icons';
import { agentColor, toneColor } from '@/lib/agent-color';
import { type RowHue, rowTint, toneColor } from '@/lib/agent-color';

export function ActionTile({
icon: Icon,
label,
hue,
onPress,
destructive,
disabled,
}: {
icon: LucideIcon;
label: string;
/** Curated destination hue; never derived from the label. */
hue: RowHue;
onPress: () => void;
destructive?: boolean;
disabled?: boolean;
Expand All @@ -27,7 +30,7 @@ export function ActionTile({
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(disabled) }}
>
<IconTile icon={Icon} tint={destructive ? toneColor('danger') : agentColor(label)} />
<IconTile icon={Icon} tint={destructive ? toneColor('danger') : rowTint(hue)} />
<Text
className={`min-w-0 flex-1 text-sm ${destructive ? 'text-destructive' : 'text-muted-foreground'}`}
>
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
routerPush,
signOutFn,
} from '@/components/profile-screen.test-helpers';
import { i18n } from '@/i18n';
import { AFTER_INTERACTIONS_FALLBACK_MS } from '@/lib/hooks/use-after-interactions';
import { act, type ReactTestInstance } from '@/test/renderer';
import { createTestQueryClient, waitFor } from '@/test/render-with-providers';
Expand Down Expand Up @@ -148,6 +149,51 @@ describe('ProfileScreen deferred queries', () => {
unmount();
});

it('gives every profile destination its documented row-palette hue', async () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(keys.providers, {
providers: [{ provider: 'github', email: 'dev@kilo.ai' }],
});
queryClient.setQueryData(keys.organizations, [
{ organizationId: 'org-1', organizationName: 'Kilo', role: 'admin' },
]);

const { renderer, unmount } = await mountProfile(queryClient);
const root = renderer.root;

// The destination table from `agent-color.ts`: the hue identifies the row's
// destination, so it is passed explicitly and never derived from the label.
const expectRowHue = (title: string, hue: string) => {
const rows = findConfigureRows(root, title);
expect(rows).toHaveLength(1);
expect(rows[0]?.props.hue).toBe(hue);
};
expectRowHue(i18n.t('common.codeReviewer'), 'honey');
expectRowHue(i18n.t('common.securityAgent'), 'honey');
expectRowHue(i18n.t('common.prReview'), 'gold');
expectRowHue(i18n.t('profile.manageOrganization'), 'lime');
expectRowHue(i18n.t('common.preferences'), 'sage');
expectRowHue(i18n.t('tour.tutorialLabel'), 'sage');
expectRowHue('GitHub', 'moss');

const actionTiles = root.findAll(
node => typeof node.type === 'string' && (node.type as string) === 'ActionTile'
);
expect(actionTiles).toHaveLength(4);
const hueByLabel = new Map(actionTiles.map(tile => [tile.props.label as string, tile]));
expect(hueByLabel.get(i18n.t('profile.feedback'))?.props.hue).toBe('fern');
expect(hueByLabel.get(i18n.t('profile.privacyChoices'))?.props.hue).toBe('fern');
expect(hueByLabel.get(i18n.t('common.signOut'))?.props.hue).toBe('fern');

// Delete account takes the same family step, and the danger tone still wins
// inside ActionTile.
const deleteAccount = hueByLabel.get(i18n.t('profile.deleteAccount'));
expect(deleteAccount?.props.hue).toBe('fern');
expect(deleteAccount?.props.destructive).toBe(true);

unmount();
});

it('paints the linked account row without an entering animation once the providers load', async () => {
providersQueryFn.mockResolvedValue({
providers: [{ provider: 'github', email: 'dev@kilo.ai' }],
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/components/profile-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function ProfileScreen() {
icon={GitPullRequest}
title={t('common.codeReviewer')}
subtitle={t('profile.codeReviewerSubtitle')}
hue="honey"
className="rounded-lg bg-secondary px-3"
disabled={!agentScope}
onPress={() => {
Expand All @@ -186,6 +187,7 @@ export function ProfileScreen() {
icon={ShieldCheck}
title={t('common.securityAgent')}
subtitle={t('profile.securityAgentSubtitle')}
hue="honey"
className="rounded-lg bg-secondary px-3"
disabled={!agentScope}
last
Expand All @@ -207,6 +209,7 @@ export function ProfileScreen() {
icon={GitMerge}
title={t('common.prReview')}
subtitle={t('profile.prReviewSubtitle')}
hue="gold"
className="rounded-lg bg-secondary px-3"
last
onPress={() => {
Expand Down Expand Up @@ -240,6 +243,7 @@ export function ProfileScreen() {
: t('profile.manageOrganization')
}
subtitle={orgName}
hue="lime"
className="rounded-lg bg-secondary px-3"
disabled={!orgRole}
last
Expand All @@ -260,6 +264,7 @@ export function ProfileScreen() {
icon={SlidersHorizontal}
title={t('common.preferences')}
subtitle={t('profile.preferencesSubtitle')}
hue="sage"
className="rounded-lg bg-secondary px-3"
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/preferences' as Href);
Expand All @@ -271,6 +276,7 @@ export function ProfileScreen() {
<ConfigureRow
icon={BookOpenCheck}
title={t('tour.tutorialLabel')}
hue="sage"
className="rounded-lg bg-secondary px-3"
last
onPress={() => {
Expand Down Expand Up @@ -336,6 +342,7 @@ export function ProfileScreen() {
title={providerLabel(p.provider)}
subtitle={p.email}
subtitleNumberOfLines={1}
hue="moss"
className="rounded-lg bg-secondary px-3"
last={index === data.providers.length - 1}
/>
Expand All @@ -349,19 +356,27 @@ export function ProfileScreen() {
<ActionTile
icon={MessageSquare}
label={t('profile.feedback')}
hue="fern"
onPress={() => {
showFeedbackPrompt(userId);
}}
/>
<ActionTile
icon={Lock}
label={t('profile.privacyChoices')}
hue="fern"
onPress={showPrivacyChoices}
/>
<ActionTile icon={LogOut} label={t('common.signOut')} onPress={confirmSignOut} />
<ActionTile
icon={LogOut}
label={t('common.signOut')}
hue="fern"
onPress={confirmSignOut}
/>
<ActionTile
icon={Trash2}
label={t('profile.deleteAccount')}
hue="fern"
destructive
disabled={deletePending}
onPress={confirmDeleteAccount}
Expand Down
21 changes: 17 additions & 4 deletions apps/mobile/src/components/ui/configure-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type ReactNode } from 'react';
import { Pressable, useWindowDimensions, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { type Tint, toneColor, type ToneKey } from '@/lib/agent-color';
import { type RowHue, rowTint, type Tint, toneColor, type ToneKey } from '@/lib/agent-color';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';

Expand Down Expand Up @@ -53,6 +53,12 @@ type ConfigureRowProps = {
* accent instead of a hue hashed from its title.
*/
tone?: ToneKey;
/**
* Curated destination hue (see `RowHue`). A row identifies a destination, so
* its colour is chosen once in the table and passed explicitly here; it is
* never derived from the title. A semantic tone outranks it.
*/
hue?: RowHue;
onPress?: () => void;
disabled?: boolean;
trailing?: ReactNode;
Expand All @@ -68,6 +74,7 @@ export function ConfigureRow({
subtitle,
subtitleNumberOfLines,
tone,
hue,
onPress,
disabled,
trailing,
Expand All @@ -77,9 +84,15 @@ export function ConfigureRow({
const colors = useThemeColors();
const { fontScale } = useWindowDimensions();
const stack = fontScale >= CONFIGURE_ROW_STACK_FONT_SCALE;
// A semantic tone overrides the shared neutral tile; a title never hashes
// into an agent hue here.
const tint: Tint = tone ? toneColor(tone) : NEUTRAL_TINT;
// A semantic tone outranks the curated destination hue, and a row carrying
// neither keeps the neutral tile that the settings surfaces outside the
// Profile screen still render (preferences-screen, account-settings-screen).
let tint: Tint = NEUTRAL_TINT;
if (tone) {
tint = toneColor(tone);
} else if (hue) {
tint = rowTint(hue);
}
// Inert rows (no onPress) and disabled rows are not tappable — hide the
// chevron so they don't look tappable, and never render pressed feedback.
const showChevron = Boolean(onPress) && !disabled;
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/ui/icon-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { cn } from '@/lib/utils';
* icon. `tint` carries the tile background/border classes and the hue the icon
* is stroked with; class names stay string literals on `tint` so NativeWind's
* static scanner compiles them.
*
* `tint` is the single explicit entry point for a tile's colour: a caller
* resolves it from the shared palette (`rowTint` / `toneColor`) and passes it
* in. No caller may derive a tint from a label, title, translation or hash of
* any string — that made a row's colour change with the language.
*/
export function IconTile({
icon: Icon,
Expand Down
Loading
Loading