diff --git a/apps/mobile/scripts/generate-theme-colors.mjs b/apps/mobile/scripts/generate-theme-colors.mjs
index 51768576fd6..dcf13863d2e 100644
--- a/apps/mobile/scripts/generate-theme-colors.mjs
+++ b/apps/mobile/scripts/generate-theme-colors.mjs
@@ -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) {
diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
index fdb114f7491..119f6a8bc99 100644
--- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx
@@ -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,
diff --git a/apps/mobile/src/components/agents/markdown-palette.test.ts b/apps/mobile/src/components/agents/markdown-palette.test.ts
index 4d52fc33a4f..c293c27e227 100644
--- a/apps/mobile/src/components/agents/markdown-palette.test.ts
+++ b/apps/mobile/src/components/agents/markdown-palette.test.ts
@@ -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', () => {
diff --git a/apps/mobile/src/components/profile-action-tile.mounted.test.tsx b/apps/mobile/src/components/profile-action-tile.mounted.test.tsx
index ea7e4cb302b..a89a299eaf2 100644
--- a/apps/mobile/src/components/profile-action-tile.mounted.test.tsx
+++ b/apps/mobile/src/components/profile-action-tile.mounted.test.tsx
@@ -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';
@@ -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;
});
@@ -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');
@@ -100,12 +108,45 @@ 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', () => {
@@ -113,6 +154,7 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Delete Account',
+ hue: 'fern',
destructive: true,
onPress: () => undefined,
})
@@ -133,6 +175,7 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
+ hue: 'fern',
onPress: () => undefined,
})
);
@@ -161,6 +204,7 @@ describe('ActionTile mounted treatment', () => {
createElement(ActionTile, {
icon: MessageSquare,
label: 'Feedback',
+ hue: 'fern',
destructive: true,
onPress: () => undefined,
})
diff --git a/apps/mobile/src/components/profile-action-tile.tsx b/apps/mobile/src/components/profile-action-tile.tsx
index 688c54628bc..d3ee77330f4 100644
--- a/apps/mobile/src/components/profile-action-tile.tsx
+++ b/apps/mobile/src/components/profile-action-tile.tsx
@@ -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;
@@ -27,7 +30,7 @@ export function ActionTile({
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(disabled) }}
>
-
+
diff --git a/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
index ede3ce46b40..8685c30f024 100644
--- a/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
+++ b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
@@ -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';
@@ -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' }],
diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx
index 77ea31a9cb1..5592f5fda37 100644
--- a/apps/mobile/src/components/profile-screen.tsx
+++ b/apps/mobile/src/components/profile-screen.tsx
@@ -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={() => {
@@ -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
@@ -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={() => {
@@ -240,6 +243,7 @@ export function ProfileScreen() {
: t('profile.manageOrganization')
}
subtitle={orgName}
+ hue="lime"
className="rounded-lg bg-secondary px-3"
disabled={!orgRole}
last
@@ -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);
@@ -271,6 +276,7 @@ export function ProfileScreen() {
{
@@ -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}
/>
@@ -349,6 +356,7 @@ export function ProfileScreen() {
{
showFeedbackPrompt(userId);
}}
@@ -356,12 +364,19 @@ export function ProfileScreen() {
-
+
void;
disabled?: boolean;
trailing?: ReactNode;
@@ -68,6 +74,7 @@ export function ConfigureRow({
subtitle,
subtitleNumberOfLines,
tone,
+ hue,
onPress,
disabled,
trailing,
@@ -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;
diff --git a/apps/mobile/src/components/ui/icon-tile.tsx b/apps/mobile/src/components/ui/icon-tile.tsx
index 492f54ca023..b40fd07cfcd 100644
--- a/apps/mobile/src/components/ui/icon-tile.tsx
+++ b/apps/mobile/src/components/ui/icon-tile.tsx
@@ -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,
diff --git a/apps/mobile/src/global.css b/apps/mobile/src/global.css
index 196c5271a9b..57f4ac7ea3e 100644
--- a/apps/mobile/src/global.css
+++ b/apps/mobile/src/global.css
@@ -79,6 +79,34 @@
--agent-sky: #2c7fb0;
--agent-sky-tile-bg: #2c7fb01a;
--agent-sky-tile-border: #2c7fb033;
+
+ /* Curated row-hue palette — the logo yellow's own family.
+ * Six steps walk one warm band (50deg -> 105deg, an 11deg step) at one fixed
+ * chroma/value per theme, straddling the brand hue (--primary olive #4f5a10,
+ * h~71, and the yellow #e8f27a). Status hues sit outside the band (danger
+ * 8deg, warn 37deg, good 150deg, info 220deg), so no step reads as a status
+ * colour. Tile pairs are the hue + 1a (10%) / + 33 (20%).
+ * Growth: a new row takes its section's step, a new section the next step in
+ * reading order; past fern, extend one 11deg step toward the cool end.
+ */
+ --row-honey: #7c6c1d;
+ --row-honey-tile-bg: #7c6c1d1a;
+ --row-honey-tile-border: #7c6c1d33;
+ --row-gold: #7a7c1d;
+ --row-gold-tile-bg: #7a7c1d1a;
+ --row-gold-tile-border: #7a7c1d33;
+ --row-lime: #697c1d;
+ --row-lime-tile-bg: #697c1d1a;
+ --row-lime-tile-border: #697c1d33;
+ --row-sage: #587c1d;
+ --row-sage-tile-bg: #587c1d1a;
+ --row-sage-tile-border: #587c1d33;
+ --row-moss: #467c1d;
+ --row-moss-tile-bg: #467c1d1a;
+ --row-moss-tile-border: #467c1d33;
+ --row-fern: #357c1d;
+ --row-fern-tile-bg: #357c1d1a;
+ --row-fern-tile-border: #357c1d33;
}
/* Focus design tokens — DARK (FD palette) */
@@ -148,6 +176,26 @@
--agent-sky: #6bb5e0;
--agent-sky-tile-bg: #6bb5e01a;
--agent-sky-tile-border: #6bb5e033;
+
+ /* Curated row-hue palette (dark values) — same warm band as the light block. */
+ --row-honey: #ebda84;
+ --row-honey-tile-bg: #ebda841a;
+ --row-honey-tile-border: #ebda8433;
+ --row-gold: #e9eb84;
+ --row-gold-tile-bg: #e9eb841a;
+ --row-gold-tile-border: #e9eb8433;
+ --row-lime: #d6eb84;
+ --row-lime-tile-bg: #d6eb841a;
+ --row-lime-tile-border: #d6eb8433;
+ --row-sage: #c4eb84;
+ --row-sage-tile-bg: #c4eb841a;
+ --row-sage-tile-border: #c4eb8433;
+ --row-moss: #b1eb84;
+ --row-moss-tile-bg: #b1eb841a;
+ --row-moss-tile-border: #b1eb8433;
+ --row-fern: #9eeb84;
+ --row-fern-tile-bg: #9eeb841a;
+ --row-fern-tile-border: #9eeb8433;
}
}
@@ -215,6 +263,26 @@
--color-agent-sky-tile-bg: var(--agent-sky-tile-bg);
--color-agent-sky-tile-border: var(--agent-sky-tile-border);
+ /* Curated row-hue colors (Tailwind classes: bg-row-honey, bg-row-honey-tile-bg, ...) */
+ --color-row-honey: var(--row-honey);
+ --color-row-honey-tile-bg: var(--row-honey-tile-bg);
+ --color-row-honey-tile-border: var(--row-honey-tile-border);
+ --color-row-gold: var(--row-gold);
+ --color-row-gold-tile-bg: var(--row-gold-tile-bg);
+ --color-row-gold-tile-border: var(--row-gold-tile-border);
+ --color-row-lime: var(--row-lime);
+ --color-row-lime-tile-bg: var(--row-lime-tile-bg);
+ --color-row-lime-tile-border: var(--row-lime-tile-border);
+ --color-row-sage: var(--row-sage);
+ --color-row-sage-tile-bg: var(--row-sage-tile-bg);
+ --color-row-sage-tile-border: var(--row-sage-tile-border);
+ --color-row-moss: var(--row-moss);
+ --color-row-moss-tile-bg: var(--row-moss-tile-bg);
+ --color-row-moss-tile-border: var(--row-moss-tile-border);
+ --color-row-fern: var(--row-fern);
+ --color-row-fern-tile-bg: var(--row-fern-tile-bg);
+ --color-row-fern-tile-border: var(--row-fern-tile-border);
+
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
diff --git a/apps/mobile/src/lib/agent-color.ts b/apps/mobile/src/lib/agent-color.ts
index a1b1fc32366..571b4ccc311 100644
--- a/apps/mobile/src/lib/agent-color.ts
+++ b/apps/mobile/src/lib/agent-color.ts
@@ -122,3 +122,92 @@ export function agentColor(name: string): Tint {
// flow-sensitive indexing resolves without `!`.
return AGENT_RAMP[index] ?? AGENT_RAMP[0];
}
+
+/**
+ * Curated row-hue palette — the logo yellow's own family.
+ *
+ * A menu row identifies a *destination*, so its colour is chosen once, here,
+ * and passed explicitly. It is never derived from a label, title, translation
+ * or hash of a string: the same row renders the same hue in every language.
+ *
+ * The rule: six steps walk the logo yellow's own warm band (50deg -> 105deg,
+ * an 11deg step) at one fixed chroma/value per theme, straddling the brand hue
+ * (`--primary` olive `#4f5a10`, h~71, and the yellow `#e8f27a`). The status
+ * hues sit outside the band (danger 8deg, warn 37deg, good 150deg, info
+ * 220deg), so no curated step can be mistaken for a status colour.
+ *
+ * Destination table (profile sections, reading order):
+ *
+ * | Section | Destinations | Step |
+ * | --------------- | ----------------------------------- | ----- |
+ * | Agents | Code Reviewer, Security Agent | honey |
+ * | Reviews | PR Review | gold |
+ * | Organization | Manage/View organization | lime |
+ * | App | Preferences, Tutorial | sage |
+ * | Linked accounts | provider rows | moss |
+ * | Actions | Feedback, Privacy choices, Sign out | fern |
+ * | reserved | Delete account | `danger` tone, never a family step |
+ *
+ * Growth rule: a new row takes its section's step; a new section takes the next
+ * step in reading order; when the walk passes `fern`, extend the family one
+ * 11deg step toward the cool end and register the token the same way. A
+ * destination never takes a hue from a label, title, translation or hash, and
+ * never a status hue.
+ */
+export type RowHue = 'honey' | 'gold' | 'lime' | 'sage' | 'moss' | 'fern';
+
+export const ROW_PALETTE = {
+ honey: {
+ hueClass: 'bg-row-honey',
+ hueTextClass: 'text-row-honey',
+ hueBorderClass: 'border-row-honey',
+ tileBgClass: 'bg-row-honey-tile-bg',
+ tileBorderClass: 'border-row-honey-tile-border',
+ hueThemeKey: 'rowHoney',
+ },
+ gold: {
+ hueClass: 'bg-row-gold',
+ hueTextClass: 'text-row-gold',
+ hueBorderClass: 'border-row-gold',
+ tileBgClass: 'bg-row-gold-tile-bg',
+ tileBorderClass: 'border-row-gold-tile-border',
+ hueThemeKey: 'rowGold',
+ },
+ lime: {
+ hueClass: 'bg-row-lime',
+ hueTextClass: 'text-row-lime',
+ hueBorderClass: 'border-row-lime',
+ tileBgClass: 'bg-row-lime-tile-bg',
+ tileBorderClass: 'border-row-lime-tile-border',
+ hueThemeKey: 'rowLime',
+ },
+ sage: {
+ hueClass: 'bg-row-sage',
+ hueTextClass: 'text-row-sage',
+ hueBorderClass: 'border-row-sage',
+ tileBgClass: 'bg-row-sage-tile-bg',
+ tileBorderClass: 'border-row-sage-tile-border',
+ hueThemeKey: 'rowSage',
+ },
+ moss: {
+ hueClass: 'bg-row-moss',
+ hueTextClass: 'text-row-moss',
+ hueBorderClass: 'border-row-moss',
+ tileBgClass: 'bg-row-moss-tile-bg',
+ tileBorderClass: 'border-row-moss-tile-border',
+ hueThemeKey: 'rowMoss',
+ },
+ fern: {
+ hueClass: 'bg-row-fern',
+ hueTextClass: 'text-row-fern',
+ hueBorderClass: 'border-row-fern',
+ tileBgClass: 'bg-row-fern-tile-bg',
+ tileBorderClass: 'border-row-fern-tile-border',
+ hueThemeKey: 'rowFern',
+ },
+} as const satisfies Record;
+
+/** Curated destination hue for a profile row. */
+export function rowTint(hue: RowHue): Tint {
+ return ROW_PALETTE[hue];
+}
diff --git a/apps/mobile/src/lib/hooks/theme-colors.generated.ts b/apps/mobile/src/lib/hooks/theme-colors.generated.ts
index 6a458b7aebe..16613c6b217 100644
--- a/apps/mobile/src/lib/hooks/theme-colors.generated.ts
+++ b/apps/mobile/src/lib/hooks/theme-colors.generated.ts
@@ -29,6 +29,12 @@ export const lightColors = {
agentKilocode: '#B27214',
agentCoral: '#C25647',
agentSky: '#2C7FB0',
+ rowHoney: '#7C6C1D',
+ rowGold: '#7A7C1D',
+ rowLime: '#697C1D',
+ rowSage: '#587C1D',
+ rowMoss: '#467C1D',
+ rowFern: '#357C1D',
} as const;
export const darkColors = {
@@ -60,4 +66,10 @@ export const darkColors = {
agentKilocode: '#F2B05F',
agentCoral: '#F28B7A',
agentSky: '#6BB5E0',
+ rowHoney: '#EBDA84',
+ rowGold: '#E9EB84',
+ rowLime: '#D6EB84',
+ rowSage: '#C4EB84',
+ rowMoss: '#B1EB84',
+ rowFern: '#9EEB84',
} as const;
diff --git a/apps/mobile/src/lib/hooks/use-theme-colors.contrast.test.ts b/apps/mobile/src/lib/hooks/use-theme-colors.contrast.test.ts
index 3988ed80858..801c068efeb 100644
--- a/apps/mobile/src/lib/hooks/use-theme-colors.contrast.test.ts
+++ b/apps/mobile/src/lib/hooks/use-theme-colors.contrast.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
+import { ROW_PALETTE } from '@/lib/agent-color';
import { darkColors, lightColors } from '@/lib/hooks/use-theme-colors';
import {
DEFAULT_TOKEN_COLOR,
@@ -15,6 +16,7 @@ vi.mock('expo-router', () => ({ DarkTheme: {}, DefaultTheme: {} }));
// The contrast assertions below fail if a shipped pair regresses below 4.5:1.
const MIN_TEXT_RATIO = 4.5;
+const MIN_NON_TEXT_RATIO = 3;
type Rgb = readonly [number, number, number];
@@ -184,3 +186,29 @@ describe('warn foreground token contrast (WCAG AA text)', () => {
expect(ratio).toBeGreaterThanOrEqual(MIN_TEXT_RATIO);
});
});
+
+describe('row palette contrast (WCAG 1.4.11 non-text)', () => {
+ // A 16px stroked glyph on a 30x30 tile is a non-text graphic, so WCAG 1.4.11
+ // asks for 3:1: measured against the row surface (`secondary`) and against the
+ // pre-tinted tile the glyph paints on (the hue at 10% alpha over `secondary`).
+ const themes = [
+ ['light', lightColors],
+ ['dark', darkColors],
+ ] as const;
+
+ it('every curated step clears 3:1 on secondary and on its tinted tile', () => {
+ for (const [themeName, colors] of themes) {
+ for (const [step, tint] of Object.entries(ROW_PALETTE)) {
+ const hue = colors[tint.hueThemeKey];
+ const tile = compositeHex(`${hue}1a`, colors.secondary);
+ expect(
+ contrastRatio(hue, colors.secondary),
+ `${step} vs secondary (${themeName})`
+ ).toBeGreaterThanOrEqual(MIN_NON_TEXT_RATIO);
+ expect(contrastRatio(hue, tile), `${step} vs tile (${themeName})`).toBeGreaterThanOrEqual(
+ MIN_NON_TEXT_RATIO
+ );
+ }
+ }
+ });
+});