diff --git a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx index e04913daa8..3ea9d2c51a 100644 --- a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx @@ -334,3 +334,33 @@ describe('ModelPickerContent deferred search', () => { }); }); }); + +// The composer's current model arrives as the bridge's `currentValue`. The row +// for that id must carry `selected`, or the picker shows no visible selected +// state for the model the composer is editing (model-selected finding: the +// DeepSeek V4.1 Flash row read as star-only). `selected` is what renders the +// trailing Check (see model-selector.mounted.test.tsx). +describe('ModelPickerContent selected row', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + buildSearchCalls.values.length = 0; + listCommitLengths.values.length = 0; + slotState.bridge = { ...makeBridge(), currentValue: 'remote-model-17' }; + }); + + it('marks exactly the bridge current model as the selected row', async () => { + const renderer = await mount(); + + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + const selectedIds = findByType(renderer.root, 'ModelPickerOptionRow') + .filter(node => node.props.selected === true) + .map(node => (node.props.option as SessionModelOption).id); + /* eslint-enable typescript-eslint/no-unsafe-member-access */ + + expect(selectedIds).toEqual(['remote-model-17']); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/question-card.mounted.test.tsx b/apps/mobile/src/components/agents/question-card.mounted.test.tsx index fb07a0c3e7..0e4338a4ff 100644 --- a/apps/mobile/src/components/agents/question-card.mounted.test.tsx +++ b/apps/mobile/src/components/agents/question-card.mounted.test.tsx @@ -303,3 +303,20 @@ describe('QuestionCard custom answer selection', () => { expect(a11yMocks.moveA11yFocus).toHaveBeenCalledTimes(1); }); }); + +describe('QuestionCard custom answer placeholder contrast', () => { + // The custom answer field swaps its fill to `bg-primary` once its radio or + // checkbox is active. A placeholder painted for the plain background + // (`mutedForeground`, near-black) is invisible on that fill, so the field's + // purpose cannot be read (ios session-answer-kb-up finding). The placeholder + // colour must follow the fill, the same way the typed text does. + it('paints the placeholder with the fill foreground once the custom answer is active', async () => { + const renderer = await renderCard(makeQuestion()); + + expect(customInput(renderer.root)?.props.placeholderTextColor).toBe('#6F6A61'); + + press(customChoice(renderer.root)); + + expect(customInput(renderer.root)?.props.placeholderTextColor).toBe('#ffffff'); + }); +}); diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx index 2085a0bc6c..f2d1120f68 100644 --- a/apps/mobile/src/components/agents/question-card.tsx +++ b/apps/mobile/src/components/agents/question-card.tsx @@ -338,7 +338,14 @@ export function QuestionCard({ handleCustomTextChange(qIndex, text); }} placeholder={t('agentChat.questionCard.typeYourOwnAnswerPlaceholder')} - placeholderTextColor={colors.mutedForeground} + // The active field swaps its fill to `bg-primary`, so the + // placeholder follows the fill's foreground like the + // typed text and the option label do; the muted grey is + // painted for the plain background and is unreadable on + // the olive fill. + placeholderTextColor={ + isCustomActive ? colors.primaryForeground : colors.mutedForeground + } editable={!interactionDisabled} accessibilityLabel={t('agentChat.questionCard.typeYourOwnAnswer')} accessibilityState={{ disabled: interactionDisabled }} diff --git a/apps/mobile/src/components/consent/consent-card.mounted.test.tsx b/apps/mobile/src/components/consent/consent-card.mounted.test.tsx index d822ef9b42..8f019c00c2 100644 --- a/apps/mobile/src/components/consent/consent-card.mounted.test.tsx +++ b/apps/mobile/src/components/consent/consent-card.mounted.test.tsx @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { act, TestRenderer } from '@/test/renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CONSENT_DISCLOSURE_MAX_FONT_SCALE, ConsentCard } from './consent-card'; +import { i18n } from '@/i18n'; const mockedAcceptConsent = vi.hoisted(() => vi.fn()); const mockedReadConsent = vi.hoisted(() => vi.fn()); @@ -195,6 +196,34 @@ describe('ConsentCard', () => { expect(singleSwitch(renderer.root).props.value).toBe(true); }); + it('keeps the privacy line clear of the pinned actions', () => { + // Finding 2 (the sentence cut in half at the footer boundary) is fixed at + // the base revision by pinning the disclosure into the footer above the + // actions, so the footer edge can no longer cut it and the actions cannot + // overlay it. The earlier assertion that the sentence was the tail of the + // scrolling body no longer holds: the sentence has left the scroller. + const renderer = mountCard('onboarding'); + const scroller = renderer.root.findByType('ScrollView' as never); + + const privacyPrefix = i18n.t('consent.privacyPolicyPrefix'); + const privacyLine = renderer.root.findAll(node => { + const text = node.children + .filter((child): child is string => typeof child === 'string') + .join(''); + return (node.type as string) === 'Text' && text.length > 0 && text.includes(privacyPrefix); + }); + expect(privacyLine).toHaveLength(1); + const line = privacyLine[0]; + if (!line) { + throw new Error('privacy line not found'); + } + expect(hasAncestorOfType(line, 'ScrollView')).toBe(false); + + // The pinned actions are outside the scroller, so they never cover it. + const primary = findButton(renderer.root, 'Accept and continue'); + expect(scroller.findAll(node => node === primary)).toHaveLength(0); + }); + it('accepts with optional on when the switch is untouched', async () => { const renderer = mountCard('onboarding'); const btn = findButton(renderer.root, 'Accept and continue'); diff --git a/apps/mobile/src/components/tour/tour-screen.mounted.test.tsx b/apps/mobile/src/components/tour/tour-screen.mounted.test.tsx index b990d765fd..54ad5ae69b 100644 --- a/apps/mobile/src/components/tour/tour-screen.mounted.test.tsx +++ b/apps/mobile/src/components/tour/tour-screen.mounted.test.tsx @@ -218,15 +218,18 @@ describe('TourScreen', () => { unmount(); }); - it('presents the header as a modal with a top-left eyebrow and no back control', async () => { + it('presents the header as a modal with a centred eyebrow and no back control', async () => { const { renderer, unmount } = await mountTour(); // The tour is presented `modal`, so the header must take the modal // clearance rather than re-adding the status-bar inset the native sheet // already owns (the dead band above the eyebrow the owner reported). The - // eyebrow stays top-left and the fork keeps no back control. + // eyebrow is centred because every other element on the screen — the icon, + // the title, the cards and the Skip action — is centred, and a top-left + // eyebrow read as a stranded label beside that column (home-quick-tour + // finding). The fork keeps no back control. const header = renderer.root.findByProps({ modal: true }); - expect(header.props.centerTitle).toBe(false); + expect(header.props.centerTitle).toBe(true); expect(header.props.showBackButton).toBe(false); expect(header.props.onBack).toBeUndefined(); expect(header.props.eyebrow).toBe('tour.eyebrow'); diff --git a/apps/mobile/src/components/tour/tour-screen.tsx b/apps/mobile/src/components/tour/tour-screen.tsx index 1c8997bac0..78c1965749 100644 --- a/apps/mobile/src/components/tour/tour-screen.tsx +++ b/apps/mobile/src/components/tour/tour-screen.tsx @@ -182,9 +182,11 @@ export function TourScreen() { {/* The tour is presented `modal` (apps/mobile/src/app/(app)/_layout.tsx), so the header takes the modal clearance instead of re-adding the status-bar inset the native sheet already owns — no dead band above - the eyebrow. The eyebrow stays top-left and the fork has no back - control to return to. */} - + the eyebrow. The eyebrow is centred (`centerTitle`): the fork body, + which is the only other content, is a centred column, so a top-left + eyebrow read as a stranded label beside it (home-quick-tour finding). + The fork has no back control to return to. */} +