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