Round 2, exploratory, no PRs yet

CurrencyTransfer UI polish, round 2

Ten small improvements found by auditing the client app area by area, each with today's version and a few ways to improve it. Every image is a screenshot of the real dev app with the change applied live. Click any screenshot to see it full size.

ItemRecommendedOptions
Skipping the 2FA prompt should take one click, not two screensB. One screen, three clear choices4
Show password rules before submit and fix the change-password formB. Live checklist and an eye on every field3
Date of birth picker offers wrong days and pre-fills 1980B. Three typed boxes3
Dashboard pads empty lists with fake cards and blank spaceB. Say what comes next3
Make the phone menu button look like a menub. Menu icon and brand mark4
Make warning boxes readable and the 2FA prompt actionablec. Calm callout plus a 2FA call to action3
Name the best quote and show what the others costB. Recipient amount first3
Lead each transfer page with its real statusC. Status header at the top3
Transfer history: readable status, a Trade ID, dates on phonesB. Status says what to do next3
Show whose account the money goes to, in lists and at ConfirmV2. Payee card, then a real summary3

1 of 10

Skipping the 2FA prompt should take one click, not two screens

After each sign-in, clients without 2FA get a full-page prompt. SKIP doesn't skip. It opens a second screen with the same title, illustration and headline, and asks for SKIP again. The only permanent opt-out, 'Do not show this message again', sits below that second button, so most clients press SKIP twice at every sign-in without ever seeing it. The second screen also says SKIP continues 'to your profile', but it goes to account selection and the dashboard.

Why it happens

In two_factor_auth_notices/index.html.haml:19, SKIP is a link_to new_two_factor_auth_notice_path, a GET to a second page, instead of a submit. Only new.html.haml has the form that posts to TwoFactorAuthNoticesController#create, and its skip checkbox comes after the submit button. create sets skip_two_factor_auth only when params[:skip] == 'skip', then redirects through ApplicationController#after_sign_in_path_for (account_selection_path). SessionsController#need_two_factor_auth_notice? shows the prompt again at the next sign-in until that flag is true.

Today

Today, screen 1 after sign-in (desktop): GET STARTED or SKIP
Today, screen 2 after SKIP: same title, illustration and headline, SKIP again, the opt-out below the button, and 'continue to your profile'
Today on a phone (390px): screen 1
Recommended: B. It removes the second screen and makes every outcome one click, while the prompt stays a real nudge rather than something easy to scroll past. 'Set up 2FA' is still the strongest action and 'Remind me next time' keeps today's default. 'Don't ask me again' is offered openly instead of hiding behind a second SKIP. There's no controller or database change, because both skip buttons post the form that exists today. Deleting screen 2 also deletes the wrong 'continue to your profile' copy.

A. One screen, same card (minimal)

Size XS

Screen 1 stays as it is, but SKIP now submits straight away. The 'Do not show this message again' checkbox moves up from screen 2 and sits directly above SKIP, so clients see it before they click. Screen 2 is deleted, which also removes the wrong 'continue to your profile' line.

Variant A (desktop): SKIP submits on screen 1, with the opt-out checkbox directly above it
  • Skipping takes one click, and the opt-out is on the first screen
  • No new copy or styles: it only moves existing pieces
  • No controller change, because create already reads the checkbox
  • Keeps the all-caps title, the long bold headline and the 'use to login' copy
  • Opting out for good still takes two actions (tick, then SKIP)
How it would be built

In index.html.haml, replace the SKIP link_to with form_tag two_factor_auth_notices_path. Inside it, put the ct-checkbox-control label moved from new.html.haml (id and name skip, value skip), then a submit button with the existing btn-primary btn-negative classes. Delete new.html.haml, and either limit the routes to only: %i[index create] or keep new as a redirect to index for one release. create is unchanged. The design tooling's common/ct.js login() walks the old two-screen flow and needs a one-line update.

app/views/two_factor_auth_notices/index.html.hamlapp/views/two_factor_auth_notices/new.html.hamlapp/controllers/two_factor_auth_notices_controller.rbconfig/routes.rb

B. One screen, three clear choices

RecommendedSize S

One calmer card. It has a navy, sentence-case title, 'Protect your account with 2FA', and two plain sentences on what 2FA asks for (a 6-digit code from an app) and why. Then come three explicit actions: 'Set up 2FA' (primary), 'Remind me next time' (outline) and a quiet 'Don't ask me again' text button. Each is one click. Both skip buttons submit the existing form, so the server logic is unchanged.

Variant B (desktop): Set up 2FA, Remind me next time, and a quiet Don't ask me again
Variant B on a phone (390px)
  • Every outcome takes one click, and 'Remind me next time' says exactly what will happen
  • The permanent opt-out is visible without a hidden checkbox but quieter than setting up, so the security nudge stays
  • Shorter, accurate copy in the product's navy and grey, with no all-caps and no wrong 'profile' line
  • New copy and a few auth-card styles need sign-off
  • 'Don't ask me again' becomes easier to find than today's checkbox, which may lower 2FA take-up a little
How it would be built

Rewrite index.html.haml as one form_tag two_factor_auth_notices_path. 'Set up 2FA' stays a link to new_two_factor_auth_path. 'Remind me next time' is a plain submit. 'Don't ask me again' is %button{ type: :submit, name: :skip, value: :skip }, which create already turns into skip_two_factor_auth = true. Delete new.html.haml and its route as in A. Styles under .two-factor-notice-container: title 24/30 bold $ct-dark; lead 16/24 $ct-dark-lighter, centred, text-wrap: balance, with '6-digit code' kept on one line; stacked 50px buttons with 12px gaps; tertiary button underlined in $ct-dark-lighter. 'Learn more' uses color.adjust($ct-highlight, $lightness: -8%) (#1276a1, 5.1:1). Optional: skip the update in create when params[:skip] is absent. Today every reminder writes false, and the first write (from nil) creates a PaperTrail version.

app/views/two_factor_auth_notices/index.html.hamlapp/views/two_factor_auth_notices/new.html.hamlapp/controllers/two_factor_auth_notices_controller.rbconfig/routes.rbapp/frontend/application/src/assets/styles/authentication/authentication-styles.scss

C. Two steps, but the second is a real confirmation

Size S

Screen 1 stays, with SKIP relabelled 'Not now'. Screen 2 stops repeating screen 1 and asks a real question, 'Continue without 2FA?'. Below it are one line on the risk and one on where to turn 2FA on later. 'Set up 2FA' is still offered first. The opt-out checkbox sits right above the skip button, which now says what it does: 'Continue without 2FA'.

Variant C (desktop): the second step becomes a confirmation, with the opt-out right above the skip button
  • Keeps deliberate friction before an account goes without 2FA, if the business wants that
  • The second screen has a purpose, and the checkbox is seen before the click
  • Contained change: one view rewritten and one label
  • Everyone who skips still sees two screens at every sign-in
  • Frequent skippers will learn to click through both, which weakens the message
How it would be built

In new.html.haml, keep the Back top bar and the form_tag. Replace the illustration, headline and two paragraphs with the title and two short lines. Then add 'Set up 2FA' (link to new_two_factor_auth_path), the existing skip checkbox, and the submit relabelled 'Continue without 2FA'. In index.html.haml, relabel SKIP 'Not now'. No controller or route change. Shares the title and lead styles with B.

app/views/two_factor_auth_notices/new.html.hamlapp/views/two_factor_auth_notices/index.html.hamlapp/frontend/application/src/assets/styles/authentication/authentication-styles.scss

D. Dashboard reminder instead of a sign-in screen

Size M

No interruption at sign-in. Clients land on the dashboard and see a slim white banner above the promotions. It has a shield icon, 'Protect your account with 2FA', one line on what it involves, 'Set up 2FA' and 'Not now'. 'Not now' hides it for a week. This mirrors the dashboard's existing mobile-app banner (show_mobile_app_banner / hide_mobile_app_banner).

Variant D (desktop): reminder banner at the top of the real dashboard, above the referral promo
Variant D on a phone (390px)
  • Nothing between sign-in and the dashboard
  • The reminder sits where clients work and stays until they act or snooze it
  • Builds on an existing pattern and settings endpoint
  • Much easier to ignore than a full-page prompt, so 2FA take-up will likely fall
  • Largest change (Rails, API and Angular), and it stacks with the referral promo, so it needs a rule for which banner shows first
How it would be built

Remove the two_factor_auth_notices branch from SessionsController#after_sign_in_path_for, or keep it only for the first sign-in after activation. Add store_accessor :additional, :two_factor_reminder_snoozed_until; no migration is needed, as with hide_mobile_app_banner. The dashboard API returns show_two_factor_reminder when OTP is off, skip_two_factor_auth is not set and the snooze date has passed. Permit the new key in ProfileSettingsController. The Angular banner links to /#/2fa. 'Not now' calls profileService.updateProfileSettings with a date 7 days ahead. Decide the banner's priority against the promotion slider.

app/controllers/sessions_controller.rbapp/models/account_credentials.rbapp/controllers/api/v1/dashboards_controller.rbapp/views/api/v1/dashboards/show.json.jbuilderapp/controllers/api/v1/profile_settings_controller.rbapp/frontend/application/src/app/dashboard/components/dashboard-page/dashboard-page.component.htmlapp/frontend/application/src/app/dashboard/components/dashboard-page/dashboard-page.component.ts

Open questions

  • Should 'Don't ask me again' exist for business accounts at all, or should 2FA become required above a certain volume? That decides between B (open opt-out) and C (deliberate friction).
  • Should 'Remind me next time' mean the next sign-in (today's behaviour) or a quieter gap such as 7 days? A gap needs a timestamp in account_credentials.additional.
  • Brand blue #1691c6 is 3.56:1 on white. That fails AA for normal-size links such as 'Learn more', and for white button text below 18.66px bold. The variants use #1276a1 (5.1:1) only for new small links. Should links, and possibly primary buttons, move to the darker shade app-wide?

Also spotted

  • Notice copy grammar: 'a unique number you use to login' should use 'sign in'. On screen 2, 'If you want to setup 2FA ... at anytime' should read 'set up' and 'any time'.
  • On a phone, the screen 1 headline breaks inside the word ('two-' / 'factor authentication'), and the all-caps title wraps to two lines (see the today phone image).
  • Pressing SKIP without the box ticked still calls update(skip_two_factor_auth: false). The first time, that changes nil to false and writes a PaperTrail version, because the column is tracked in account_credentials.rb.

2 of 10

Show password rules before submit and fix the change-password form

Production refuses passwords shorter than 8 characters. It also refuses passwords of 15 characters or fewer that don't mix a capital, a lower-case letter and a number, and any password found in a breach list. Sign-up, the reset-link page and Settings > Change Password show none of this, so clients learn the rules from an error after a full submit that also clears the field. The reset page uses Devise's default labels with the second label pressed against the first field, and its card is square, unlike the other auth cards. Sign-in and reset have no show/hide eye. Change Password asks for the new password before the current one, with vague labels, invalid autocomplete tokens and a reset link that wraps the email address.

Why it happens

The rules live only on the server: devise.rb:144 (password_length 8..128), AccountCredentials#check_password_complexity (account_credentials.rb:158) and the not_pwned validator, all switched on in production by require_complex_password. No view renders them. passwords/edit.haml keeps simple_form's default labels, and its form class is 'ct-box' without 'login-form'. The reveal code in sign_up.js:89-119 comes after an early return that only passes on the sign-up page, so sign-in and reset never get it. change-password-page.component.html:29-50 lists new_password and password_confirmation before current_password, with autocomplete values that aren't valid tokens. Its only validator is required, so any non-empty value turns the field green.

Today

Today, sign-up (desktop): 'sunshine2024' has no capital, and no rule is shown before submit
Today, reset-link page: default labels, the second label pressed against the first field, a square card, no rules
Today, Settings > Change Password: new password first, current password last, and a reset link that wraps the email address
Today on a phone (390px): sign-in (left) has only a decorative lock; sign-up (right) has the eye but no rules
Recommended: B. B catches the audit's exact failure ('sunshine2024' has no capital) before the first submit, on all three screens that set a password. The 2 x 2 layout stays compact on a 390px phone. It also fixes the other reported issues: Change Password order and labels, valid autocomplete tokens, the reset card and labels, and an eye on sign-in. And a field only turns green when the server will accept the password. If client-side rules aren't wanted yet, A is a safe first step: the same fixes, using copy only.

A. Static rule line and tidy forms (minimal)

Size S

One plain line under every new-password field: 'Use 8 or more characters, including a capital letter, a lower-case letter and a number. Or use 16 or more characters of any kind.' The reset page gets 'New password' and 'Confirm new password', room between the fields, and the same rounded card and title style as sign-in. Change Password runs Current, New, Confirm, with valid autocomplete tokens, and 'Forgot your current password? Email me a reset link' sits under the current field. Sign-up's existing eye is switched on for sign-in and reset too.

Variant A, sign-up: one static rule line under the field
Variant A, reset page: clear labels, rule line, eye, spacing and the rounded sign-in card
Variant A, Change Password: Current, New, Confirm, with the rule line and a short reset link under Current
  • Every rule is visible before the first submit, on every screen that sets a password
  • Copy, markup order and one moved JS block, with no validation logic to keep in sync
  • The right field order and autocomplete tokens let browsers and password managers fill the right fields
  • Clients still have to check the rules themselves, so 'sunshine2024' can still fail, just less often
  • Two lines of grey text are easy to skim past
How it would be built

Keep the rule text in one I18n key and show it as a simple_form hint on the new-password inputs, plus a <p> in the Angular page. In edit.haml: add 'login-form' to the form class and 'title-header' to the title; set labels to 'New password' and 'Confirm new password' with autocomplete 'new-password'; and render the reset_password_token input without the visible wrapper (wrapper: false), which also removes about 77px of dead space. In sign_up.js, move the reveal block above the early return and bind it to every password input that has a .ct-input-icon. In Change Password, reorder the three ct-text-input-container blocks, relabel them, set autocomplete to current-password and new-password, and move the reset link under Current password as a button (or an a with an href) so it's keyboard-reachable.

app/views/devise/registrations/new.hamlapp/views/devise/passwords/edit.hamlapp/views/devise/sessions/new.hamlapp/assets/javascripts/sign_up.jsapp/frontend/application/src/app/settings/profile-settings/pages/change-password-page/change-password-page.component.htmlconfig/locales/en.yml

B. Live checklist and an eye on every field

RecommendedSize M

Under every new-password field, a compact 2 x 2 checklist ticks as the client types: 8+ characters, A capital letter, A lower-case letter, A number, with 'Or use 16 or more characters of any kind' underneath. Unmet items keep an empty circle and navy text, so the gap stands out: for 'sunshine2024' it's the capital letter. Confirm fields add a 'Passwords match' line, every password field gets the show/hide eye (Settings too), and a field only turns green once it will be accepted. Change Password gets A's order, labels and reset link, the reset page gets the same checklist, and the breach error is rewritten in plain words.

Variant B, sign-up: live checklist for 'sunshine2024', with the capital letter the one rule left
Variant B, Change Password: current first, checklist and match line complete, eye on every field
Variant B on a phone (390px): sign-in with the password revealed (left) and sign-up with the checklist (right)
  • Catches the exact failure from the audit before the first submit and shows which rule is missing
  • One pattern across sign-up, reset and Settings, with a match check and an eye everywhere, so phone typos are caught before the 10-attempt lockout
  • The green field state follows the rules, so a password only looks accepted when the server will accept it
  • The rules would live in Ruby, JS and TypeScript unless they're served from one config, so they could drift
  • Adds about 70px under the field, and the breach check still runs only on submit, so a fully ticked password can still be refused (the new error copy explains why)
How it would be built

Rails: add a _password_rules partial under the new-password input on registrations/new and passwords/edit. Add password_fields.js with the rule check (length of at least 8, and either 16 or more characters or [A-Z], [a-z] and [0-9]) plus the reveal code moved out of sign_up.js and bound to every password field. Angular: add a small standalone PasswordRulesComponent (input: password) under New password, a 'Passwords match' line, and a show/hide button option on ct-text-input-container. A custom validator makes the green state mean the rules pass. Change Password also gets A's order, labels, autocomplete and link. Serve the numbers (8, 16) from one place, for example the Rails config exposed through /api/v1/configurations and a data attribute on the Rails forms. Rewrite en.yml not_pwned as: 'This password has appeared in a known data breach, so it could be guessed. Please choose a different one.' Colours: ticks color.adjust($ct-alert, $lightness: -15%) (#286e0b, 6.3:1); empty circles and the eye at rest $ct-dark-lightest (#7a92a8, 3.2:1); unmet text $ct-dark. At phone widths, the grid switches to auto columns so each item stays on one line.

app/views/devise/registrations/new.hamlapp/views/devise/passwords/edit.hamlapp/views/devise/sessions/new.hamlapp/views/devise/shared/_password_rules.html.haml (new)app/assets/javascripts/sign_up.jsapp/assets/javascripts/password_fields.js (new)app/frontend/application/src/app/settings/profile-settings/pages/change-password-page/change-password-page.component.htmlapp/frontend/application/src/app/settings/profile-settings/pages/change-password-page/change-password-page.component.tsapp/frontend/application/src/app/shared/components/form/text-input-container/text-input-container.component.htmlconfig/locales/en.ymlapp/frontend/application/src/assets/styles/authentication/authentication-styles.scss

C. One adaptive hint line

Size M

A single line under the field. It starts as the full rule, then names only what's missing as the client types ('Add a capital letter, or use 16 or more characters.'), and turns into a green 'Meets the password rules' tick when done. Confirm fields show 'Passwords match'. Change Password takes the reset action out of the form entirely: a quiet row under the card reads 'Don't know your current password? Email me a reset link'.

Variant C, sign-up: one line naming the missing rule
Variant C, Change Password: hint and match lines complete, and the reset action in a quiet row under the card
  • Takes the least room: one line that names only the gap
  • One short live message works well on phones and with screen readers
  • Moving the reset action out of the form keeps an instant email send away from the fields
  • Clients see what's missing, not the whole rule set at a glance, which makes it less scannable than a checklist
  • Needs more copy and logic than ticks to phrase each combination (several gaps, too short)
How it would be built

The plumbing is the same as B, but with one aria-live hint element whose text is rebuilt on each input. Missing parts are joined into one sentence, for example 'Add a capital letter and a number, or use 16 or more characters.' The warning icon is ct-icon-exclamation-mark-outline in $ct-action-text (#a4822a, 3.6:1) with navy text; the done state is #286e0b. On Change Password, move the (click) link into a row under the card, inside the same limit-max-width container as Back and Save, and make it a button so it can be focused.

app/views/devise/registrations/new.hamlapp/views/devise/passwords/edit.hamlapp/views/devise/sessions/new.hamlapp/assets/javascripts/password_fields.js (new)app/frontend/application/src/app/settings/profile-settings/pages/change-password-page/change-password-page.component.htmlapp/frontend/application/src/app/settings/profile-settings/pages/change-password-page/change-password-page.component.tsconfig/locales/en.yml

Open questions

  • Should the password rule numbers (8 characters, and 16 or more with any mix) come from one source, such as /api/v1/configurations plus a data attribute on the Rails forms, so the checklist can't drift from AccountCredentials#check_password_complexity?
  • Is '8+ characters' fine in the checklist? It keeps every item on one line at 390px; A's static line spells it out as '8 or more characters'.
  • Change Password's reset link emails as soon as it's clicked. Should it ask for confirmation, or at least show which address it's sending to before sending?

Also spotted

  • Reset page: the hidden reset_password_token input sits inside a visible simple_form wrapper (display:block, 47px tall, 30px margin), leaving about 77px of empty space above the first field (devise/passwords/edit.haml:22).
  • Change Password: any non-empty New password turns green, because the only validator is required. 'sunshine2024', which production rejects, looks accepted until Save (checked in the dev app).
  • Change Password: the reset link is an <a> with a (click) handler and no href, so it can't be reached with the Tab key.
  • Sign-in: tapping the decorative lock icon takes focus off the password field, so on a phone it closes the keyboard (the icon sits over the input with no mousedown handling).
  • The breach-check error ('insufficiently secure as it has been found in public lists of passwords leaked from other sites', config/locales/en.yml:44) doesn't tell the client what to do next.

3 of 10

Date of birth picker offers wrong days and pre-fills 1980

Choosing the month rebuilds the Date list with the previous month's length. March stops at 29 and May, July, October and December stop at 30, while February offers 31. Picking 31 and then February is accepted and silently stored as 2 March 1980. Year starts at 1980, so it looks already answered while Date and Month are grey placeholders.

Why it happens

date-select.component.ts:56 counts days with new Date(year, month.number, 0), which is the last day of the previous month. The count only reruns on a month change (never on a year change), and the chosen day is never checked against it. setDate() then lets new Date() roll 31 February over to 2 March, which setBirthDate formats as a valid 1980-03-02, so the API cannot catch it. getYears() (line 66) presets year = 1980 and lists 1900 first.

Today

Today, desktop 1440: Month set to March, the Date list stops at 29
Today: 31 February 1980 is accepted, and the page holds birth_date 1980-03-02
Today, phone 390 at rest: Date and Month are grey placeholders, but Year already says 1980
Recommended: B. It removes the bug class instead of patching it: there is no day list to miscount, and one check covers 31 February. It is also the fastest to fill on a phone (numeric keypad, browser autofill), with visible labels for each part. If this sprint only has room for a hotfix, A's logic change is XS and can ship first.

A. Fix the dropdowns

Size XS

Keep the three dropdowns and correct the logic. Count the days for the chosen month and year, recount when either changes, and clear the day with a short message when it no longer fits. Year starts empty with the newest years first, and the first box is labelled Day instead of Date.

March picked first: the Day list now runs to 31, and Year starts empty
31, then 1980, then February: the day clears and the field says why
Phone 390 at rest: Day, Month and Year all read as unanswered
  • Smallest change: about 15 lines in one component, with nothing new to design or test visually.
  • Looks exactly like today and like the form's other dropdowns (country, nationality).
  • Ends the silent 31 February to 2 March shift: the day clears and the field says why.
  • Still three long lists on phones: reaching 1992 means scrolling or typing into a 126-item Year list.
  • The three parts are still labelled only by grey placeholders (2.4:1).
How it would be built

getNumberOfDays: new Date(year || 2000, month.number + 1, 0).getDate() (2000 keeps 29 February available until a year is picked). Call it from the Year (change) as well. If day > count, set day = null, show the message and emit null so setBirthDate clears the stored value. Otherwise the page would keep the previously emitted date, so setBirthDate needs a null guard. getYears: no default year, newest first. Placeholder 'Day'. Spec cases: 31 March, 29 February in leap and non-leap years, 31 then February. Rendered by patching the live DateSelectComponent instance, so the lists shown are the real component's output.

app/frontend/application/src/app/registration/components/date-select/date-select.component.tsapp/frontend/application/src/app/registration/components/date-select/date-select.component.htmlapp/frontend/application/src/app/registration/components/date-select/date-select.component.spec.tsapp/frontend/application/src/app/registration/pages/account-details-page/account-details-page.component.ts

B. Three typed boxes

RecommendedSize S

Replace the dropdowns with three short number boxes labelled Day, Month and Year. A hint line reads 'For example, 27 3 1984', and one plain message appears when the date is not real. Phones get the numeric keypad, and browsers can autofill a saved birthday.

31 3 1985 typed: the birthday today's picker hides when March is chosen first
31 2 1980: both boxes are flagged, with one message that names the month
Recommended, phone 390 at rest: labelled boxes, nothing pre-filled (numeric keypad on focus)
  • Quickest to complete on a phone: numeric keypad, no scrolling through 126 years, and autocomplete bday-day/bday-month/bday-year lets browsers fill it in one tap.
  • No day list to get wrong: one check catches 31 February and names the month and year in the message.
  • Visible labels above each box instead of grey placeholders. This is the proven pattern for dates people know by heart (the GOV.UK date input).
  • About 50px taller (hint plus three small labels) on an already long form.
  • Needs its own validation and tests (real date, not in the future, sensible year range), so it is more work than A.
How it would be built

Swap the three ng-selects for inputs with inputmode="numeric", pattern="[0-9]*", maxlength 2/2/4, autocomplete bday-day/bday-month/bday-year, and one aria-describedby hint. Validate on blur and before Next: month 1 to 12, year 1900 to this year, a real date (build the Date and check the month did not roll), not in the future. Emit the Date only when it is real, otherwise emit null, so setBirthDate never stores a rolled-over date. The (complete) output stays, so the page barely changes. Widths 72/72/104px on the existing 45px input style. Error text in $ct-warning-alt #b62d2a (6.2:1), borders in $ct-warning.

app/frontend/application/src/app/registration/components/date-select/date-select.component.htmlapp/frontend/application/src/app/registration/components/date-select/date-select.component.tsapp/frontend/application/src/app/registration/components/date-select/date-select.component.scssapp/frontend/application/src/app/registration/components/date-select/date-select.component.spec.tsapp/frontend/application/src/app/registration/pages/account-details-page/account-details-page.component.ts

C. One field with a read-back

Size M

A single DD / MM / YYYY field that formats as the client types, with the date read back in words underneath ('31 March 1985') so they can see it was understood.

31/03/1985 typed and read back as 31 March 1985
31/02/1980: the field is flagged with the same message as B
Phone 390 at rest: one field, with the format explained in the hint
  • One tap and eight digits: the fastest entry on any device, and the most compact of the typed options.
  • The read-back catches a swapped day and month from clients used to US order, and doubles as a confirmation.
  • One value to validate, with the same plain message as B.
  • A good input mask is fiddly (backspace over separators, paste, caret jumps, screen reader output), and package.json has no mask library, so this is custom code.
  • Clients who type the month first only notice if they read the read-back.
How it would be built

One input (inputmode numeric, autocomplete bday) with a small formatter that inserts ' / ' after the day and month, handles paste and backspace, and parses browser autofill, which often arrives as YYYY-MM-DD. Same validation and null-emit rule as B. The read-back line uses ct-icon-tick plus the date in words in navy, announced with aria-live="polite".

app/frontend/application/src/app/registration/components/date-select/date-select.component.htmlapp/frontend/application/src/app/registration/components/date-select/date-select.component.tsapp/frontend/application/src/app/registration/components/date-select/date-select.component.scssapp/frontend/application/src/app/registration/components/date-select/date-select.component.spec.tsapp/frontend/application/src/app/registration/pages/account-details-page/account-details-page.component.ts

Open questions

  • Is there a minimum age for personal accounts? If it is 18, B and C can say so on the spot ('You need to be 18 or over to open a personal account') instead of failing later.
  • Does the API validate birth_date beyond format? Today it receives an already rolled-over, valid-looking date (1980-03-02), so it cannot catch 31 February.
  • A rolled-over pick can only produce 1 to 3 March, 1 May, 1 July, 1 October or 1 December. Is a quick query of personal clients with those birth dates worth it, to spot KYC mismatches before activation checks do?

Also spotted

  • Verified: after picking 31 and then February, the account details page holds birth_date 1980-03-02 (read with ng.getComponent). The value looks valid, so the API cannot reject it.
  • date-select never recounts days when the year changes, so leap-year handling is only right by accident.
  • getDate() (returning to a saved date) sets day and year from split('-') as strings such as '05', which never match the numeric list items. The saved day shows as a free-text value (from reading the code, not rendered).
  • Field error text (.ct-errors in global.scss) is #e53935 at 13px on white, 4.2:1, which is below AA 4.5:1. The grey placeholders on this form (#99abbc) are 2.4:1.

4 of 10

Dashboard pads empty lists with fake cards and blank space

With fewer than 3 (or 4 to 5) recipients, the grid is padded with faded 'Jane Doe' cards that 'last traded' today, plus an Add tile styled unlike the cards, next to a second Add link. With no quotes, the quotes heading and chart caption sit over nothing, and on phones the empty list keeps a fixed 380px height. Real cards have also lost their individual or business icon, and the masked account line is 1.8:1.

Why it happens

recent-recipients-list.component.ts pads the list to 3 or 6 with placeholders. Index 0 renders the Add tile, and the rest render ct-recipient-card with isPlaceholder, which fills in 'Jane Doe' and new Date(). The padding also hides a layout flaw: .list is flex with space-between, so a short last row splits to the edges (verified on quotes with 2 pairs). currency-pairs-list.html has no empty state, and dashboard.scss applies the fixed .hide-excess heights and VIEW MORE regardless of item count. recipient-card.component.ts overwrites recipientType with beneficiary.blockchain_address, which renders ct-icon-profile-null.

Today

Today, new client, 1440: the quotes heading and caption sit over nothing, then an Add tile, two ghost Jane Doe cards dated today, and a second Add link
Today, new client, 390, from the buttons to the page end: a 380px blank block, two stacked add actions, ghost cards and VIEW MORE
Today, real account (4 recipients), 1440: an Add tile with a different border and radius, a faded Jane Doe, no type icons, and a 1.8:1 account line
Recommended: B. It fixes every reported problem (no fake people, no blank block, one add action). It also tells a new client what each section is for, while the dashboard keeps the same structure for everyone. A is the fallback for the smallest diff. C only pays off if a guided first run is wanted as a feature in its own right.

A. Remove the fakes

Size S

The smallest honest fix. Delete the Jane Doe cards, and hide the quotes section until the first quote, as Recent activity already does when empty. Show one dashed 'Add a recipient' tile only where the grid has a free slot, with the header link hidden while it shows.

New client, 1440: no quotes section until there is a quote, one dashed Add tile and no duplicate link
New client, 390: the page ends after the tile
Real account, 1440: the tile fills the free slot next to Sonia, with icons restored and the account line at 5.4:1
  • Smallest diff: template conditions and one SCSS block, and the placeholder code in recipient-card can be deleted.
  • Nothing fake or empty is left. On phones the page ends right after the tile: 445px from the top of the buttons instead of 1,241px.
  • The tile now matches the cards (12px radius, card height, 1px dashed border) instead of competing with them.
  • A new client gets no hint that recent quotes will appear on the dashboard.
  • The add action moves between the tile and the header link depending on how many recipients there are.
How it would be built

Replace the placeholders array with showAddTile (no recipients, or recipients.length % 3 !== 0). Delete isPlaceholder and initPlaceholderInformation, and wrap currency-pairs-list in @if (currencyPairs().length). Switch .dashboard-card-list-container > .list to a 3-column CSS grid (gaps 16px and 10px) so a short last row stays left-aligned; the fake cards were masking this. Apply .hide-excess and VIEW MORE only when there are more items than the collapsed height shows. Below 768px the list collapses to three cards, so keep the header button there and render the tile only when there are no recipients; otherwise the add action could hide behind VIEW MORE. Shared fixes, included in every variant: recipientType = beneficiary.type (and walletAddress = blockchain_address), .account-detail in $ct-dark-lighter, and flex: 1 on the card's text column so the name fade stops clipping short names once the icon is back.

app/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.tsapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.htmlapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.scssapp/frontend/application/src/app/dashboard/components/currency-pairs-list/currency-pairs-list.component.htmlapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.tsapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.scssapp/frontend/application/src/assets/styles/pages/dashboard.scss

B. Say what comes next

RecommendedSize S

Keep both sections. When one is empty, show a single white card that says what will appear there and offers one action (Get a quote, Add a recipient). Clients with recipients see only real cards plus the existing '+ Add recipient' link, and every card gets the same three lines, with 'Not traded yet' when there is no date.

New client, 1440: each empty section says what will appear and offers one action
Recommended, new client, 390: the same cards stacked, with each action under its text
Real account, 1440: real cards only, the header link kept, and 'Not traded yet' where there is no date
  • A new client learns what each section is for and gets one clear next step, without scaffolding.
  • Every client sees the same structure, so nothing jumps when the first quote or recipient arrives.
  • Built from existing parts (white 12px cards, tinted icon circles, the secondary button style from the balances round), with AA text throughout.
  • Two explanatory cards are more to scroll past on a phone than hidden sections (659px against 445px in A).
  • 'Get a quote' goes to the same place as the Make a Transfer button just above it.
How it would be built

One small empty-state block (icon circle, title, one line, secondary button) reused by both lists. Hide the 30-day caption, the header link and VIEW MORE while a list is empty. Recipients: drop the placeholder padding entirely and render only real cards. recipient-card shows 'Not traded yet' when last_traded_at is null, with the nickname in $ct-dark. Grid, hide-excess and the shared icon and contrast fixes as in A. Action labels are navy on white (12.8:1), because brand-blue text is 3.6:1.

app/frontend/application/src/app/dashboard/components/currency-pairs-list/currency-pairs-list.component.htmlapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.tsapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.htmlapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.scssapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.tsapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.htmlapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.scssapp/frontend/application/src/assets/styles/pages/dashboard.scss

C. Get started checklist

Size M

Until the first transfer, replace both empty sections with one 'Get ready for your first transfer' card with three steps: add a recipient, get a quote, book your first transfer. Steps tick off from data /api/v1/dashboard already returns (beneficiaries, currency_pairs, has_traded). Each list comes back as soon as it has content, and the card goes away after the first trade.

New client, 1440: one checklist replaces both empty sections
New client, 390
After a first recipient is saved, step 1 ticks off and the recipients section returns (stubbed with a live recipient, so the card shows an old trade date)
  • Turns the empty state into guidance with visible progress ('1 of 3 done').
  • No new API: all three ticks come from the existing dashboard payload.
  • Explains the path to a first transfer in one place and in order, which the two headed lists cannot.
  • A new component with its own states to build and test, sitting under the separate CONGRATULATIONS card.
  • 'Get a quote' and 'Book your first transfer' repeat the Make a Transfer button just above.
How it would be built

Render the card in dashboard-page when is_verified && !has_traded, with done flags from beneficiaries.length, currency_pairs.length and has_traded. Hide each quick-access list while it is empty; once it has items it renders as in B (real cards only, header link). Each row is one link, with the chevron as the affordance; done rows show a green tick and 'Done'. Grid, hide-excess and shared fixes as in A.

app/frontend/application/src/app/dashboard/components/getting-started-card (new: ts, html, scss)app/frontend/application/src/app/dashboard/components/dashboard-page/dashboard-page.component.htmlapp/frontend/application/src/app/dashboard/components/currency-pairs-list/currency-pairs-list.component.htmlapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.tsapp/frontend/application/src/app/dashboard/components/recent-recipients-list/recent-recipients-list.component.htmlapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.tsapp/frontend/application/src/app/dashboard/components/recipient-card/recipient-card.component.scssapp/frontend/application/src/assets/styles/pages/dashboard.scss

Open questions

  • Keep 'Get a quote' in B's empty quotes card, or drop it because Make a Transfer sits just above? Without it the card becomes a single informative line.
  • If C appeals, should the checklist replace the 'CONGRATULATIONS! YOU HAVE BEEN ACTIVATED' card instead of sitting under it?
  • Brand-blue link text (#1691c6) is 3.6:1 on white, below AA at 16px, so the new actions here use navy labels. Should the product adopt a darker link shade everywhere (for example $ct-blue #195c7d, 7.3:1)?

Also spotted

  • Verified: the quick-access .list is flex with justify-content: space-between. A client with 2 (or 5) recent quote pairs sees the cards pushed to the far edges with an empty middle slot (x=377 and x=973 at 1440px). The recipients grid hides the same flaw only because the fake cards pad every row, so removing them needs the switch to CSS grid.
  • recipient-card.component.ts overwrites recipientType with beneficiary.blockchain_address (null for bank recipients), so real cards render ct-icon-profile-null. walletAddress is never assigned, so wallet recipients would show 'Account No.: ••••' instead of the wallet (from reading the code).
  • Once the type icon is restored, the .head-line fade overlay clips the last letter of short names ('Santiago' renders as 'Santiagc'), because the card's text column shrinks to its content. Seen while rendering and fixed in every variant with flex: 1.
  • dashboard-page.component.html passes pending_documents_count && pending_documents_count.length !== 0 to hasPendingDocuments. The count is a number, so .length is always undefined and the check only works by accident (from reading the code).
  • The '+ ADD RECIPIENT' header link is brand blue at 16px bold, 3.6:1 on white, which is below AA.

5 of 10

Make the phone menu button look like a menu

Below 480px the full logo is hidden and the drawer toggle shows the CT brand symbol instead of a menu icon. Nothing says it opens navigation. It doesn't change while the drawer is open, and screen readers announce an unlabelled button. On phone it is the only way to reach Transfer History, Recipients, Rate Alerts, Market Orders and Notifications (the bell is hidden at this size). People expect a logo to go home, not open a menu, so anyone who doesn't guess stays on the dashboard.

Why it happens

header-responsive.scss (phone block, 0-479px) hides .ct-logo and sets the drawer button's icon to content: url(ct-logo-symbol.svg). Commit 33dca3849 (Sep 2021, account switcher layout) swapped the button's mobile-menu.svg for the brand symbol. The button in header.component.html has no aria-label, no aria-expanded and no open-state class, even though HeaderComponent already tracks showDrawer.

Today

Today at 390px: the only menu control is the brand mark at the top left.
Today with the drawer open: the mark doesn't change, so nothing shows the drawer is open or how to close it.
Recommended: b. B fixes the real failure, that nobody can tell the mark opens navigation, with the icon everyone recognises. It adds a visible open/close state that matches the drawer's own active pill, gives screen readers a proper label, and still keeps CT's mark on phone. It's XS: two files, using glyphs already in the app's icon font. If first-time clarity for less confident clients matters most, C is the step up.

a. Menu icon

Size XS

The minimal fix. The app's existing three-line glyph (ct-icon-mobile-menu, $ct-dark-lighter, 5.4:1) goes back in the same 60px cell. While the drawer is open it becomes a navy X on a light blue rounded tile, the same $ct-highlight-background pill the drawer uses for its active item. The button gets aria-label 'Open menu' or 'Close menu', plus aria-expanded.

A, closed: the app's own three-line icon in the same 60px cell.
A, open: a navy X on the drawer's light blue active tile.
  • The three-line icon is the pattern people already know for a menu, so there's nothing to guess
  • The X and tile show that the drawer is open and how to close it
  • Smallest change: two template bindings and one SCSS block, using glyphs already in the icon font
  • The CT brand disappears from the phone header
  • Icon only, so someone who doesn't know the icon still has no word to go on
How it would be built

In header.component.html, replace the button's <div class="ct-svg-icon"> with <i class="ct-icon" aria-hidden="true" [ngClass]="showDrawer ? 'ct-icon-close-cross' : 'ct-icon-mobile-menu'">. Add [attr.aria-label]="showDrawer ? 'Close menu' : 'Open menu'", [attr.aria-expanded]="showDrawer" and [class.is-open]="showDrawer" to the button. In the phone block of header-responsive.scss, drop the content: url(ct-logo-symbol.svg) rule. Set the glyph to 26px $ct-dark-lighter (the X is 18px $ct-dark). Give .is-open an inset tile: ::before, inset 8px, 12px radius, $ct-highlight-background. Keep the existing 60px cell and divider.

app/frontend/application/src/app/layout/components/header/header.component.htmlapp/frontend/application/src/app/layout/components/header/header-responsive.scss

b. Menu icon and brand mark

RecommendedSize XS

The same button as A (three-line icon, X plus tile while open, aria labels), with the CT brand symbol beside it as a plain mark, not a button, so the phone header keeps its identity. Below 360px the mark drops out so the account switcher still fits.

B, closed: menu icon first, then the brand mark as a plain mark.
B, open: a navy X on the light blue tile; the brand mark stays put.
  • The common app layout: menu first, logo beside it
  • Keeps CT's brand visible on phone
  • Same clear open state and screen-reader labels as A
  • Needs a small-screen rule: the account switcher has min-width 250px, so below 360px the mark is hidden (checked at 360 and 320px)
  • About 44px wider on the left, so long account names wrap a little sooner (they already wrap today)
How it would be built

Everything in A, plus an aria-hidden brand symbol (ct-logo-symbol.svg, cropped to about 34px) inside .ct-drawer-button, after the button. In the phone block, make .ct-drawer-button display: flex with width: auto and a 56px button; the existing border-right divider then sits after the mark. Add @media (max-width: 359px) to hide the mark and put the button back to 60px. It stays non-interactive, as the audit suggested (see the open question about making it a home link).

app/frontend/application/src/app/layout/components/header/header.component.htmlapp/frontend/application/src/app/layout/components/header/header-responsive.scss

c. Icon with a Menu label

Size XS

The three-line icon plus the word 'Menu' in navy bold. While the drawer is open it reads 'Close' with an X on the light blue tile. The visible word is also the accessible name. Below 360px the word is visually hidden and the icon stays.

C, closed: icon plus the word Menu.
C, open: the button reads Close, with an X.
  • The clearest option: the word leaves nothing to guess, which helps less confident users
  • The visible label doubles as the accessible name, so only aria-expanded is needed
  • A heavier header, and the brand mark goes
  • On phones under 360px the word has to hide, so they fall back to icon only
How it would be built

As A, with <span>{{ showDrawer ? 'Close' : 'Menu' }}</span> after the glyph (Lato 16px bold, $ct-dark; icon 24px). The button becomes auto width, about 104px, with 14px/18px padding. The open tile is inset 8px 6px. Under 360px, visually hide the span and set the button back to 60px.

app/frontend/application/src/app/layout/components/header/header.component.htmlapp/frontend/application/src/app/layout/components/header/header-responsive.scss

d. Brand mark with a chevron

Size XS

Keep the brand symbol as the button and add a small chevron (ct-icon-arrow-down) that flips up, with the same light blue tile, while the drawer is open. Adds aria-label and aria-expanded.

D, closed: brand mark kept, with a small chevron added.
D, open: the chevron flips up on the light blue tile.
  • The smallest visual change; keeps today's brand-led header
  • At least shows that it opens something, and when it is open
  • A chevron reads as a dropdown rather than navigation, and still doesn't say menu
  • Puts two chevrons in one header (the account switcher has one too)
How it would be built

Keep the symbol in the button and add <i class="ct-icon ct-icon-arrow-down" aria-hidden="true"> (16px, $ct-dark-lighter), rotated 180deg when .is-open. Use the same inset tile as A, plus aria-label and aria-expanded bindings. The button is auto width, about 84px.

app/frontend/application/src/app/layout/components/header/header.component.htmlapp/frontend/application/src/app/layout/components/header/header-responsive.scss

Open questions

  • Should B's brand mark link to the dashboard, as logos usually do, or stay a plain mark as rendered?
  • The drawer labels are still pale (#99abbc, about 2.4:1). That's the separate runner-up 'Sidebar, drawer and account menu labels are too pale to read'. Worth shipping with this?
  • Is the word 'Menu' (C) worth the extra width for CT's less tech-savvy clients?

Also spotted

  • Phone header: long account names wrap with the name left-aligned while the account type ('Company Account') stays right-aligned underneath, so the two lines don't line up. Seen at 390px with a 33-character company name; it happens today.
  • The unused assets/images/icons/mobile-menu.svg hard-codes fill #b8c4d0 (1.8:1 on white), so re-enabling it as-is would fail non-text contrast. The icon-font glyph ct-icon-mobile-menu takes a CSS colour instead.

6 of 10

Make warning boxes readable and the 2FA prompt actionable

Warning boxes use the accent yellow as their text colour: #f9c63f on #fefcf5 is 1.55:1. That box carries the messages people most need to read: the 2FA security prompt, 'Deleting your account is irreversible', and the Balances explanation that deposit details couldn't be loaded. The 2FA ENABLE button is yellow on cream too, and turns white on yellow on hover (1.68:1). The Rewards 'Select rewards account' button is white on yellow (1.59:1). The other alert types repeat the same accent-as-text pattern (success 2.6:1, info 2.0:1, primary 2.9:1). On Settings, the only sign that 2FA is off is a bare yellow '!' (1.81:1) with no words.

Why it happens

alert.scss sets each variant's text colour to its accent token (warning $ct-action, success $ct-alert, primary $ct-highlight-text, info $ct-blue-grey-text), so the text is as pale as the border. buttons.scss gives .btn-action white text on yellow and .btn-action.btn-negative yellow text, and on hover it fills yellow behind white text. The Settings row renders only a warning glyph in $ct-action-dark. alert.scss is also bundled into the Rails auth pages through limited-styles.scss, so the flash notices share the problem and the fix.

Today

2FA page today: the 'not enabled' message and the ENABLE button are yellow on cream, 1.55:1.
Delete Account today: the irreversible-action warning at 1.55:1.
Balances today with deposit details unavailable (stubbed 404, the production failure path): the explanation is pale yellow, 1.55:1.
Settings today: the only sign that 2FA is off is a bare yellow '!' (1.81:1) with no label.
Recommended: c. C fixes readability for every box at the root (B's calm callout, navy text at 12.4:1) and makes the message that matters most, the 2FA nudge, say what's wrong, why it matters and what to do, with a real button. The Settings list also states the status in words. It's still a small change: one shared SCSS file plus copy in two templates. To ship CSS only first, B is the same system change without the 2FA copy; A is the one-file fallback.

a. Darker text, same boxes

Size XS

The minimal fix. Keep today's tinted boxes and 2px borders, and switch each type's text to a dark shade of its own hue: warning $ct-action-text-dark (4.74:1 measured), danger $ct-warning-alt (5.9:1), primary $ct-blue (6.2:1), info $ct-dark-lighter (4.6:1), and success a new dark green #2f7d10 (4.9:1). Yellow-filled buttons get navy text (8:1). The ENABLE outline button's text matches the box, and its hover fills yellow with navy text.

A: the same box; text and ENABLE in $ct-action-text-dark, 4.74:1.
A: the delete warning at 4.74:1, still centred in the yellow frame.
A: the deposit error at 4.74:1.
  • Fixes all 49 .ct-alert uses and the yellow buttons from two SCSS files, with no template changes
  • Lowest risk: layout, sizes and centring stay exactly as today
  • Uses existing tokens apart from one new dark green
  • Still looks like today: heavy yellow frames, and the mustard text only just clears AA (4.74:1)
  • The 2FA prompt stays a passive notice that's easy to skip
How it would be built

Change color in .warning, .success, .danger, .primary and .info. The alert-notice, alert-info, alert-error and alert-alert flash classes follow through @extend. Add $ct-alert-text-dark: #2f7d10 to colors.scss. In buttons.scss, give .btn-action $ct-dark text and .btn-action.btn-negative $ct-action-text-dark text, with a hover and focus that fill $ct-action behind $ct-dark text instead of white. This also lifts the Rails flash notices, which share alert.scss via limited-styles.scss.

app/frontend/application/src/assets/styles/components/alert.scssapp/frontend/application/src/assets/styles/components/buttons.scssapp/frontend/application/src/assets/styles/colors.scss

b. Calm callout

Size S

Restyle every alert as a calm callout: a faint tint, 1px edge, a 4px accent bar on the left, an accent icon from the app's own icon font, and navy text (12.4:1). Two-part messages get a bold first line. Buttons inside become a white secondary button (12.8:1). Same visual language as the notification tray: white surfaces, navy text, colour only in the bar and icon.

B: tinted callout with an amber bar and icon, navy bold text (12.4:1) and a white ENABLE button.
B: bold first line, navy text, left-aligned.
B: the deposit error in navy on the calm callout.
  • Reads as a message rather than decoration; colour moves into the bar and icon
  • One consistent style for warning, error, info and success; QA'd on the generic error banner, the rewards tip and the delete page on phone
  • Still CSS-led: alert.scss plus two small template tweaks
  • Visibly changes all 49 .ct-alert uses across transfers, recipients and settings, so it needs a QA pass; centred messages become left-aligned
  • The 2FA prompt is readable but is still just a notice with an ENABLE button
How it would be built

In alert.scss, give each type a tint, a 1px edge, border-left: 4px solid in its accent, a 6px radius, $ct-dark text and 52px left padding. Draw the icon with ::before from the icon font: warning and danger \e94d, info and primary \e967, success \e990. Scope it with :not(.ct-alert-small):not(.alt) so the rate alert expiry chips and toast pills keep their look, and force left alignment on .text-center alerts. On phone (xs-flex-column), top-align the icon and left-align the button. Buttons inside a callout get white, 1px #c3ceda and $ct-dark text. Remove the inline warning icon from the 2FA template, and wrap the first delete sentence in <strong>. The Rails flash notices pick this up too via limited-styles.scss; check the ones that pass icon_classes to shared/_notices so they don't show two icons.

app/frontend/application/src/assets/styles/components/alert.scssapp/frontend/application/src/app/settings/profile-settings/pages/two-factor-authentication-page/two-factor-authentication-page.component.htmlapp/frontend/application/src/app/settings/account-settings/pages/delete-account-page/delete-account-page.component.html

c. Calm callout plus a 2FA call to action

RecommendedSize S

B for every box, plus the 2FA prompt rewritten as an action: 'Two-Factor Authentication is off', one line on why it matters ('Keep your account safe even if someone learns your password.'), and a solid 'Turn on 2FA' button that goes to the existing setup page. On Settings, a readable 'Off' label replaces the bare yellow '!'. Delete Account and Balances look as in B.

C: 'Two-Factor Authentication is off', one line on why, and a solid Turn on 2FA button (white on $ct-blue, 7.3:1).
C: Settings shows an 'Off' label (4.6:1) instead of the bare '!'.
C on a 390px phone: the card stacks and the button goes full width.
  • Turns the most important prompt into a clear next step, and the Settings list states the 2FA status in words
  • Every other box gets B's readable callout
  • Plain, short copy: what's wrong, why it matters, what to do
  • The most work of the three: copy and template changes on two pages on top of B
  • The solid button uses $ct-blue (#195c7d, 7.3:1), darker than the usual primary blue, because white on #1691c6 is only 3.6:1
How it would be built

Everything in B, then on the 2FA page replace the warning block with a B-style callout. It uses a lock glyph (\e925) instead of the '!', a title 'Two-Factor Authentication is off' (18px bold $ct-dark), the line 'Keep your account safe even if someone learns your password.' (16px $ct-dark-lighter, 5.2:1), and <a class="ct-btn" [routerLink]="['/2fa/setup']">Turn on 2FA</a> styled solid $ct-blue (17px bold, 48px tall; full width under 480px). The 'What is Two-Factor Authentication?' section stays. In profile-settings-page.component.html, swap the '!' icon for <span class="status-off">Off</span>: 13px bold $ct-action-text-dark on #fff8e7, 1px #f1dfae border, pill shape. Keep the row's existing link and chevron.

app/frontend/application/src/assets/styles/components/alert.scssapp/frontend/application/src/app/settings/profile-settings/pages/two-factor-authentication-page/two-factor-authentication-page.component.htmlapp/frontend/application/src/app/settings/profile-settings/pages/profile-settings-page/profile-settings-page.component.htmlapp/frontend/application/src/app/settings/settings.scssapp/frontend/application/src/app/settings/account-settings/pages/delete-account-page/delete-account-page.component.html

Open questions

  • The solid 2FA button uses $ct-blue (#195c7d) because white on the standard primary #1691c6 is 3.6:1. Keep it as a one-off, or darken primary buttons app-wide as a separate item?
  • B and C left-align messages that are centred today (Delete Account, some rewards and recipient notices). OK everywhere?
  • Delete Account is destructive. Should it use the red danger callout rather than amber?
  • Settings status wording: 'Off' or 'Not set up'?

Also spotted

  • If /api/v1/balances/deposit_details returns a 5xx, Balances shows two errors: the inline 'We weren't able to retrieve deposit information' box and the generic 'Sorry, something went wrong. Please refresh the page' banner (reproduced with a stubbed 502). balance.service.ts:55 sends X-Skip-Interceptor, but http-request.interceptor.ts only uses that header to skip the 404 redirect; X-Hide-Generic-Error is the one that suppresses the banner. The usual production failure is a 404 (BalancesController#deposit_details rescues partner errors), which shows only the inline box.
  • The 2FA ENABLE button (btn-action btn-negative) turns white on yellow on hover and focus, 1.68:1: .btn-action:hover fills the background while the .btn-negative hover sets white text.
  • Primary buttons across the app (for example Get Started and Invite a Friend) are white on #1691c6, 3.56:1 measured, under AA for their 16px text.
  • Delete Account: the DELETE ACCOUNT outline button is #e53935 on white, 4.23:1, just under AA for 16px bold. BACK is #b8c4d0 on #f8f8f8, 1.67:1.
  • 2FA page copy: 'a unique number you use to login' should read 'log in' (the verb).

7 of 10

Name the best quote and show what the others cost

On the Confirm Transfer step, every quote card repeats 'Your account 25,000.00 GBP'. That figure is identical for every provider and already sits in the summary bar above. Each card also has the same green BOOK button. Nothing says which quote is best or what picking another one costs: 29,029.50 vs 28,998.50 vs 28,967.50 EUR, so 31.00 and 62.00 EUR less. The best card is marked only by a pale green tint. By default only that card shows. The only sign that other providers were compared is 'SHOW MORE QUOTES', in #b8c4d0 on white (1.8:1) with no button shape, so it reads as disabled. On phones that faint toggle is the only clue a comparison happened.

Why it happens

quotation.component receives only its own quotation plus a 'highlighted' flag, so it cannot compute a gap. Its desktop and mobile blocks both hard-code the 'Your Account' column. quote-book-page marks the first card with [highlighted]="f", and the only styling for it is .ct-main-quote { background: $light-green }. The toggle is the global .ct-btn.btn-clear ($ct-blue-grey #b8c4d0) with the labels 'Show more/less quotes'. The data is already there: the API returns quotations best-first (Quote#ordered_broker_quotations uses buy_amount DESC for the sell side and sell_amount ASC for the buy side). So the gap is a subtraction against brokerQuotations[0].

Today

Today, desktop default: one quote with a pale tint, 'Your account' repeating the summary bar, and a SHOW MORE QUOTES toggle at 1.8:1 that looks disabled.
Today, expanded: 25,000.00 GBP three times and three equal BOOK buttons. Nothing says Clear Treasury is best or that the others pay 31.00 and 62.00 EUR less.
Today, phone at 390px: only the best quote shows. The faint SHOW MORE QUOTES is the only hint that other providers were compared.
Recommended: B. B fixes the view most clients actually see. In the collapsed default, and on every phone, the top card names itself best and says how far ahead it is ('31.00 EUR more than Moneycorp'), so the comparison is proven without a click. It also removes the repeated 'Your account' figure, so the amount that differs becomes the hero. A is the fallback if you want an hour-sized change. C is only worth it if you want to drop the one-quote-by-default decision.

A. Label the winner (minimal)

Size XS

Keep today's card layout. Add a 'Best rate' pill under the top card's recipient amount, and a line on the other cards: '31.00 EUR less than best'. Turn the toggle into an outlined button that counts the hidden quotes: 'Compare 2 more quotes' / 'Hide other quotes', with a chevron.

A, desktop default: a 'Best rate' pill under the recipient amount and a real 'Compare 2 more quotes' toggle. Layout otherwise unchanged.
A, expanded: the other cards say '31.00 EUR less than best' and '62.00 EUR less than best', and the columns stay aligned.
A, phone: pill under the amount and an outlined toggle in $ct-blue at 7.3:1.
  • Smallest change: one line in the card template plus a button style, and nothing else moves.
  • Answers both questions (which is best, what the others cost) in the spot people already read, under the recipient amount.
  • The toggle becomes a real control at 7.3:1, and its count shows a comparison happened.
  • Keeps the clutter: 'Your account 25,000.00 GBP' still repeats on every card, and the BOOK buttons still look equal.
  • What switching costs only appears after expanding, so most people never see it.
How it would be built

Pass [best]="brokerQuotations[0]" to ct-quotation and compute the gap there. Sell side: best.buy_amount minus buy_amount, in the buy currency ('less than best'). Buy side: sell_amount minus best.sell_amount, in the sell currency ('more to pay than best'). Add the pill or the gap line under the recipient amount in both the desktop and the mobile blocks. Give it width: 0; min-width: 100%, so the line wraps inside the column and the three cards stay aligned. Without that, each card's columns size to their own text and drift apart. Toggle label: 'Compare {{n - 1}} more quotes' / 'Hide other quotes', plus ct-icon-arrow-down. Style it white with a 1px $ct-highlight border and $ct-blue text, the same pairing as the approved 'Set a rate alert' button. If quotes tie, show 'Best rate' on each tied quote.

app/frontend/application/src/app/trade/components/quotation/quotation.component.htmlapp/frontend/application/src/app/trade/components/quotation/quotation.component.tsapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.htmlapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.scss

B. Recipient amount first

RecommendedSize S

Rebuild each card around the one number that differs. Left to right: logo, then 'Recipient gets 29,029.50 EUR' as the hero figure with a 'Best rate' pill, then one plain line with the money gap. The best card says '31.00 EUR more than Moneycorp'; the others say '31.00 EUR less than Clear Treasury'. Rate and inverse move to a quiet column, then BOOK. 'Your account' and the repeated delivery date are dropped, because the summary bar already shows both. Cards become white bordered panels, with the best one edged in green. The toggle is the same outlined button as in A.

B, desktop default: the recipient amount is the hero, and the card names itself best and says it gives 31.00 EUR more than Moneycorp, before any click.
B, expanded: 'Your account' is gone. Every card says what the recipient gets and how far behind the best it is; rate and inverse sit in a quiet column.
B, phone at 390px (recommended): full-width BOOK and toggle, and the lead over the next quote is visible while collapsed.
  • The collapsed default proves a comparison happened without a click: best rate plus how far ahead it is. That is the only view most clients see, and every phone user sees it.
  • Drops two repeated figures per card, so the eye lands on the amount that actually differs.
  • On phones, BOOK and the toggle become full-width and the card reads top to bottom.
  • A real rewrite of the quotation template (desktop and mobile blocks), plus SCSS for three breakpoints. I also checked it off-board at 820px and 600px, and it fits.
  • Drops 'Est. delivery' from the card, so a quote whose delivery date differs from the request needs a conditional line.
How it would be built

Rewrite the quotation template as one row: logo, then 'Recipient gets' plus the amount in $ct-dark at 30px with the pill, one gap line, a Rate/Inverse column, and BOOK. Pass best and next (brokerQuotations[0] and [1]) so the top card can state its lead while collapsed. Labels use $ct-blue #195c7d (7.3:1), because the app's $ct-highlight is only 3.6:1 at 12px. On the buy side the hero becomes 'You pay X GBP' and the gap reads 'X GBP more to pay'. Show a delivery line only when quotation.delivery_date differs from the quote's. Layout: a row from 768px (tighter between 768 and 991), and a stacked card with full-width BOOK and toggle below 768. Keep the 55px BOOK container so the 'I agree' / Confirm step still fits. Watch out: the template's indentation is misleading, because BOOK actually sits inside the same flex row as the logo. The green tint class .ct-main-quote becomes a green border.

app/frontend/application/src/app/trade/components/quotation/quotation.component.htmlapp/frontend/application/src/app/trade/components/quotation/quotation.component.tsapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.htmlapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.tsapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.scss

C. Ranked table, every quote visible

Size M

Show all quotes at once as a ranked table with no toggle. Columns: Provider, Recipient gets, Rate, Difference, Book. The best row is tinted, carries a 'Best rate' pill and keeps the solid BOOK. The other rows show '31.00 EUR less' and a secondary outlined Book. On phones the table collapses to three compact rows that fit where one card sits today.

C, desktop: all three quotes ranked in one table with a Difference column. The best row keeps the solid BOOK; the others get a secondary Book.
C, phone: three compact rows (logo, amount, difference, Book) in about the height of today's single card.
  • The comparison is visible without any click, which is the most direct proof of what CurrencyTransfer promises.
  • Easy to scan: amounts and differences line up in columns.
  • On phones all three quotes fit in about the height of today's single card.
  • Reverses the current choice to show one quote by default, and puts more choice on screen at the moment of booking.
  • The in-row three-step BOOK ('I agree', T&Cs, Confirm) has less room, especially the 84px phone button. The row would need to expand to full width when BOOK is pressed.
How it would be built

Always render every quotation: drop showMoreQuotations and the toggle. Add a header row and lay each quotation out on a five-column grid: provider, recipient gets, rate, difference, book. Non-best rows use a secondary outlined Book: navy text and a $ct-dark-lightest border (3.2:1). On phones the rows collapse to logo, then amount over difference, then an 84px Book. The three-step confirm must widen its row there. The ranking note under the table replaces the per-card delivery date, and should only say 'same date' when that is true.

app/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.htmlapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.tsapp/frontend/application/src/app/trade/pages/quote-book-page/quote-book-page.component.scssapp/frontend/application/src/app/trade/components/quotation/quotation.component.htmlapp/frontend/application/src/app/shared/components/button/three-step-button.component.scss

Open questions

  • Is showing one quote by default a deliberate conversion choice? If not, C (all quotes visible) is on the table, or B could simply start expanded.
  • Should the gap line name the other provider ('31.00 EUR more than Moneycorp') or stay generic ('than the next best quote')?
  • The summary bar already shows the mid-market rate (1.1662). Do you want each card to show its distance from mid-market too? It builds trust but exposes the margin, so it is a business call.
  • When two providers quote exactly the same amount, should both get the 'Best rate' pill?

Also spotted

  • The quote countdown lost its right alignment because the 'text-right' class no longer exists. At 480-767px it slides under the icon rail and reads 'ote expires in: 12s', and on phones it touches the screen edge. I saw it again in my 600px QA render; it is the auditors' runner-up.
  • An empty 10px pale green band sits under the best quote. .ct-quote-messages has min-height: 10px (trade.scss:717) and keeps the green message background even when there is no message.
  • BOOK is white on $ct-alert #41b412, which is 2.7:1: below AA even for large text.

8 of 10

Lead each transfer page with its real status

The transfer page always opens its last step with 'Hey Dev, Congratulations, your transfer has been booked successfully!', whatever state the transfer is in. Take the 1 Mar 2024 transfer, which settled on 8 Mar. It still shows the full settlement account table, 'I've made the payment, now what?' and 'Running late?'. Its only completion signal is a small Paid chip halfway down the page. The step rail never ticks: Allocate stays an empty circle next to a payment marked Sent, and Make Bank Transfer has no done state. The status itself (Awaiting funds, Settled) appears nowhere. An unpaid transfer whose pay-by time (23:59 BST, 1 Jul 2024) has long passed gets the same congratulations, with no overdue signal. Straight after booking, the page congratulates twice: the green banner and then the paragraph. On phones the rail is hidden, so nothing near the top says whether the transfer is finished.

Why it happens

trade-confirmation-page.component.html renders the 'Hey {{first_name}}, Congratulations' block at lines 27-37, and a forward-trade copy at 113-124. It does this for every trade whose settlement is not externally managed, with no status check. Only 'What happens next?' checks completed_at, and the bank table and notes have no paid condition. In recipients-allocate-page.component.ts, unallocated.amount is the API string "0.0" while getTrackerStepClasses() tests === 0, so 'done' never applies. The Make Bank Transfer header (ct-tracker-header last) has no done or warning binding at all. Everything needed is already in the trade payload: status, settlement_funds_status ('paid'), completed_at, settlement_at, remaining_amount and allocated_amount, plus the payments list.

Today

Today, settled transfer (paid, one payment sent): steps 2 and 3 stay empty circles. The step opens with 'Congratulations, your transfer has been booked successfully!' and the bank table follows; a small Paid chip is the only sign it is done.
Today, unpaid transfer whose pay-by time (23:59 BST, 1 Jul 2024) passed long ago: the same congratulations and no overdue signal.
Today, straight after booking: the same trade with its dates moved to 1 Oct 2026 via a stubbed GET. The banner and the paragraph both say congratulations.
Today, phone at 390px: the rail is hidden, so the first screen shows amounts and the allocation list but nothing says the transfer is finished.
Recommended: C. C is the only option that puts the answer on screen when the page opens on a phone, where the rail is hidden and step 3 starts below the fold. It reuses the notification-tray look you approved. It also inherits B's fold, so a settled transfer reads as a record rather than as a payment request. If a page-level element feels like too much, B is the same logic inside step 3 and one size smaller.

A. Status line (minimal)

Size S

Swap the 'Hey Dev, Congratulations' paragraph for one status line built from the trade data. Settled: 'Settled on 8 Mar 2024. We received your 40,580.58 GBP and sent 50,000.00 EUR to Santiago. Nothing left to do.' Overdue: an amber 'Payment overdue' with the missed deadline and a phone number. Not yet paid: 'Awaiting your payment', with amount, provider and pay-by time. The rail ticks Allocate when nothing is left to allocate and ticks Make Bank Transfer once paid, and shows the attention mark when overdue. The 'we emailed your trade summary' sentence moves under the booking banner.

A, settled: the rail ticks all three steps, and the congratulation becomes 'Settled on 8 Mar 2024. We received your 40,580.58 GBP and sent 50,000.00 EUR to Santiago.' The bank table still follows.
A, overdue: amber 'Payment overdue' with the missed deadline and a phone number, and the rail marks the step for attention.
A, just booked: one congratulation (the banner, which now also says where the summary was emailed), then 'Awaiting your payment' with amount, provider and deadline.
  • The smallest change that removes the misleading copy in every state, including the double congratulation after booking.
  • The rail finally tells the truth, and the string-versus-number bug behind it gets fixed.
  • The icons (tick, settlement tray, hourglass in tinted circles) reuse the notification-tray style you approved.
  • A settled transfer still shows the full bank table and the 'Running late?' notes, so the page stays long.
  • On phones the status line sits below the overview and the allocation list.
How it would be built

Add a status getter to the component. Settled: status is 'settled', title 'Settled on {{ completed_at | ctDate }}'. Overdue: not paid, not settled or closed, and settlement_at is in the past. Otherwise: awaiting. It replaces lines 27-37 and 113-124. Bind [ngClass]="{done: paid, warning: overdue}" on the last tracker header. In recipients-allocate-page, derive the unallocated amount from the trade() signal with parseFloat(remaining_amount), instead of copying it once in ngOnInit. That also fixes the carry-over between trades the auditors saw. Move the email sentence under the isNewTrade banner. The settled copy names the recipient when there is one sent payment, otherwise 'N recipients', and only says 'Nothing left to do' when the remaining amount is 0. Icons come from the CT icon font: ct-icon-tick, ct-icon-settlement, ct-icon-expiration.

app/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.htmlapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.tsapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.scssapp/frontend/application/src/app/trade/pages/trade-confirmation/trade-confirmation.component.htmlapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.ts

B. Status panel, fold once paid

Size S

The same status logic as A, shown as a bordered panel at the top of the Make Bank Transfer step, with a pale amber fill when overdue. Once the funds are paid, the settlement account table and the three notes fold behind a 'Show settlement account details' strip attached to the payment card. The Paid chip and Save as PDF stay visible. Unpaid transfers keep everything open.

B, settled: a status panel, with the payment instructions folded into one strip under the Paid card. The page ends at 957px instead of 1681px.
B, overdue: the panel turns pale amber, and nothing folds because money is still owed.
B, phone: the panel starts near the bottom of the first screen, and the fold keeps the page short.
  • A settled transfer reads as a finished record: on desktop the page ends at 957px instead of 1681px.
  • Payment instructions only appear in full while money is still owed, which lowers the risk of someone paying twice.
  • Nothing is lost for reconciliation: the reference and IBAN are one click away.
  • On phones the panel still starts near the bottom of the first screen, about 730px down on a 390x844 phone.
  • Adds a fold state to the template, and one more combination to test with the Open Banking and automated-settlement banners.
How it would be built

All of A's changes, plus: wrap ct-bank-account-table and .note-container in @if (!paid || showSettlementDetails), with a toggle button after ct-trade-settlement-header. When folded, round the blue card's bottom corners, which the table normally supplies. Panel: white, 1px $ct-blue-grey-light border, 12px radius; overdue uses a pale amber fill (#fffaf0) and a soft amber border. The toggle text is $ct-blue (7.3:1).

app/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.htmlapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.tsapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.scssapp/frontend/application/src/app/trade/pages/trade-confirmation/trade-confirmation.component.htmlapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.ts

C. Status header at the top

RecommendedSize M

Put the status in a header card above the step rail, so it is the first thing on every device. Settled: 'Settled on 8 Mar 2024', plus what was received and sent. Overdue: an amber 'Payment overdue' with a 'Payment details' button that jumps to the account details. Underneath, the rail ticks as in A, and paid-up details fold as in B. The congratulation paragraph goes entirely.

C (recommended), settled: the status is the first thing on the page, above the rail, and the details fold as in B.
C, overdue: an amber header with a 'Payment details' button that jumps to the account details.
C, phone at 390px: the answer is on screen when the page opens, even though phones hide the rail.
  • Answers 'is it done, and do I still owe money?' in the first 100px, including on phones where the rail is hidden.
  • Works as the header of the client's record of the deal, and the same component could label trades in history and notifications.
  • Same card language as the notification tray you approved: white card, navy title, tinted icon.
  • Adds a new page-level element. Straight after booking it stacks under the green banner: banner first, then 'Awaiting your payment'.
  • The status is computed in the wrapper component rather than in the step, so the change touches three components.
How it would be built

Create a small TradeStatusComponent (inputs: trade and payments) that owns the settled / overdue / awaiting logic and copy. Render it above <ct-overview> in trade-confirmation.component.html, and remove the 'Hey Dev' block from trade-confirmation-page. The fold works as in B and the rail fix as in A. The overdue 'Payment details' button scrolls to the settlement section; on phones it sits under the text. Leave the same-currency branch alone, since it has its own confirmation.

app/frontend/application/src/app/trade/components/trade-status/trade-status.component.ts (new)app/frontend/application/src/app/trade/pages/trade-confirmation/trade-confirmation.component.htmlapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.htmlapp/frontend/application/src/app/trade/pages/trade-confirmation-page/trade-confirmation-page.component.scssapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.ts

Open questions

  • Which date should 'Settled on' use: completed_at (8 Mar 2024 on this dev trade, when CT marked it settled) or the delivery date (1 Mar)? settlement_funds_received_at exists in the database but not in the trade API; exposing it would allow 'Funds received 1 Mar'.
  • Is 'Please pay now, or call us on +44 (0) 207 096 1036' the right overdue instruction for ops, or should overdue trades point to the relationship manager?
  • Should the fold also apply while an Open Banking payment is 'payment_initiated', or only once funds are marked paid?
  • Should the header show the trade reference (e.g. 20240301-BZMQPZ)? It appears nowhere on the page today, but it could be confused with the payment reference used for the bank transfer.

Also spotted

  • The 'TRADE FACILITATED BY:' footer throws 'TypeError: Cannot read properties of undefined (reading toLowerCase)' in getFacilitatedByType(). It happens on every change-detection pass whenever /api/v1/trades/:id/metadata returns create_information {}. I counted 73 console errors within a few seconds of opening the settled dev trade, and the footer renders empty.
  • The rail's done check compares remaining_amount, which arrives as the string "0.0", with === 0, so Allocate never ticks. The Make Bank Transfer header has no done state at all.
  • The CONGRATULATIONS banner text is #41b412 on #f7f7f7, 2.5:1: below AA even for large text.
  • On phones the step headings (e.g. MAKE BANK TRANSFER) keep 28px side padding while the content under them uses 8px, so the headings and their content do not line up.

9 of 10

Transfer history: readable status, a Trade ID, dates on phones

Each transfer's status (Awaiting Funds, Awaiting Payment Details, Settled) is 12px italic #b8c4d0 in an outlined box, about 1.8:1 on white. The one thing a finance manager has to act on reads like a disabled field, while the amounts beside it are bold, and every status looks the same. On phones the book and delivery dates are hidden. The two 2 Jun transfers of 50,000.00 SAR to 10,451.25 GBP can't be told apart on any screen, desktop included: they share book date, delivery date, amounts, rate and recipient. The only thing that differs is the Trade ID (CT-20240602-913 vs CT-20240602-431), and the list never shows it. On desktop the header repeats above every transfer and columns shift from row to row.

Why it happens

trade-history-list.component.scss .funds sets italic $fs-xs with $ct-blue-grey text and border (1.8:1). The phone pill reuses .ct-grey-pill with the same colours, and the date cell carries hide-xs. Each transfer renders its own <table> with its own thead and auto column widths, so headers repeat and columns drift. trade_reference is already in GET /api/v1/trades, but nothing in the SPA renders it, even though the confirmation email, the PDF and the allocation reminder all quote it as 'Trade ID'. settlement_at (the pay-by time the trade page shows) is also in the payload but unused in history.

Today

Desktop today, first two Active transfers: faint italic status boxes (1.8:1), a header above every transfer, and columns shifting between rows.
Phone today, the two 2 Jun transfers: every visible field is identical, with no dates and no Trade ID.
Completed list today: 'Settled' is the same faint box as 'Awaiting funds'.
Recommended: B. B fixes all three problems a finance manager runs into (a status you can't read, no cue for what to do, and transfers you can't tell apart) inside the current page structure. The Trade ID also links history to the emails and PDFs clients already receive. C is a good next step once B has shipped, because it reuses B's chips and Trade ID.

A. Readable status, nothing else

Size XS

A CSS-only contrast fix. The status becomes an upright 13px bold label on a soft grey chip (#445667 on #e8ecf0, 6.4:1) on desktop and phone. Wording, layout and the single colour for every status stay as they are.

Desktop: same rows, with the status as a readable grey chip.
Phone: the pill is readable, but the two 2 Jun transfers still look identical.
  • Fixes the WCAG failure with one SCSS edit, and no template or logic change
  • No layout risk: row heights and columns don't move
  • The status is now as readable as the amounts beside it
  • Every status is still the same grey, so 'Awaiting funds' doesn't stand out from 'Settled'
  • Phones still hide the dates and there is still no Trade ID, so the two 2 Jun transfers remain indistinguishable
How it would be built

Restyle .current-trade .funds > div and .current-trade .show-xs .ct-grey-pill: font-style normal, 13px/700, padding 5px 12px, border 0, radius 4px, background $ct-blue-grey-lighter, colour $ct-dark-blue-grey. Scoping it to .current-trade leaves the global .ct-grey-pill untouched elsewhere.

app/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.scss

B. Status says what to do next

RecommendedSize S

Chips are coloured by what the client has to do: amber for Awaiting funds, blue for Awaiting payment details, grey for Processing and Closed, green for Settled. Under the chip sits one deadline line and the Trade ID. The line reads 'Pay by 30 Sep 2026, 14:00' in grey, and turns into 'Overdue since 1 Jul 2024' in red once the time passes. Phones get the chip and Trade ID on one line plus a Booked and Delivery line. That stacked layout now runs up to 767px, which also fixes the cramped table at 600px. The 'still not allocated' link moves to an AA amber (5.7:1).

Desktop, real dev data: amber 'Awaiting funds' with 'Overdue since 1 Jul 2024' (it was due 1 Jul, 23:59), and each Trade ID. Before the deadline the line reads 'Pay by 30 Sep 2026, 14:00' in grey.
Phone (390px), the same two 2 Jun transfers: chip and Trade ID at the top, dates restored. They now read CT-20240602-913 and CT-20240602-431.
Completed list: 'Settled' in green, so finished transfers recede from the ones that need action.
  • The one status that needs money from the client is now the most visible thing on the row
  • The Trade ID matches what emails and PDFs already quote, and it finally separates CT-20240602-913 from CT-20240602-431
  • Keeps the page structure, so the change stays within one component's template and SCSS; every text colour is 4.9:1 or better
  • Adds a line or two to each status cell, so desktop rows grow by about 30px
  • Each transfer is still its own table, so the header still repeats and columns still shift between rows (variant C fixes that)
How it would be built

Add statusChip(trade), which returns a label and class, and payByNote(trade), built from settlement_at. If the time is still ahead it returns 'Pay by D MMM YYYY, HH:mm'; if it has passed while the trade is awaiting_funds it returns 'Overdue since D MMM YYYY'. td.funds shows the chip, the note and 'Trade ID {{trade.trade_reference}}'. On phones the centred pill becomes a flex row (chip left, Trade ID right), with a Booked/Delivery line after the table, and the stacked breakpoint moves from 479px to 767px. Add two text tokens: $ct-action-text-strong #8a5a00 (amber chip and alert) and $ct-alert-dark #2a7a0b (Settled). Chip tints reuse $ct-highlight-bg, $light-green-alt and $ct-blue-grey-lighter.

app/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.htmlapp/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.tsapp/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.scssapp/frontend/application/src/assets/styles/colors.scss

C. One aligned table per list

Size M

Each list becomes one table: the header appears once and fixed column widths keep amounts in line. Each transfer is one compact row: status chip with Trade ID, sold, rate, bought, booked and delivery dates, then PDF and view icons. Under the bought amount sits the recipient, or an amber 'Choose a recipient' link that replaces today's separate alert box. On phones each transfer becomes a card: chip and Trade ID, '50,000.00 SAR to 10,451.25 GBP', the rate, the dates, the recipient and the links.

Desktop: one header, aligned columns, and both transfers in 210px (432px today).
Phone: one card per transfer, with the Trade ID, a single 'SAR to GBP' line and the dates restored.
  • Two transfers take 210px instead of 432px, so about twice as many fit on screen
  • Columns line up down the whole list, so amounts can be scanned
  • The phone card leads with what was exchanged, in one readable line
  • A bigger rewrite of the list template and its phone layout, so it needs more QA (pagination, filters, the 'and N others' menu)
  • Replaces the 'Trade Summary' button with an icon and a chevron, which returning users will need to relearn
How it would be built

Render one <table> per list with a single thead and an @for over rows inside tbody. Use table-layout: fixed with column widths of 25/15/11/18/22/9%. Amounts sit on one line. Recipients become a sub-line under Bought, built from distinct nicknames, and the unallocated alert becomes an inline link. The actions become a download icon and a chevron link, both with aria-labels. Below 480px, rows switch to CSS-grid cards instead of hiding cells. Includes everything in B.

app/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.htmlapp/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.tsapp/frontend/application/src/app/trade/components/trade-history-list/trade-history-list.component.scss

Open questions

  • The rows say 'Awaiting payment details' but the Status filter says 'Awaiting Settlement Instructions' for the same status. Which wording should both use?
  • Should awaiting-funds transfers past their pay-by time say 'Overdue since…' (matching the bell's 'Settlement overdue'), or is that state rare enough in production to leave out?
  • Is 'Trade ID' the right label (emails and PDFs already use it), and should the history search accept it?
  • Is sentence case for statuses ('Awaiting funds') fine, instead of today's Title Case?

Also spotted

  • The trade reference that emails and PDFs call 'Trade ID' (allocation reminder, confirmation email and PDF) is never shown anywhere in the web app; there is no frontend use of trade_reference. A client can't match an email to a history row.
  • Correction to the audit: the two 2 Jun transfers (CT-20240602-913, booked 14:49, and CT-20240602-431, booked 11:30) are identical on desktop too: same book and delivery dates, amounts, rate and recipient. Showing dates on phones alone won't separate them; only the Trade ID or the booking time does.
  • The amber '51,555.00 NZD still not allocated' link in history rows is #eca82b on #fefcf5, about 2.0:1, which fails AA.
  • XCT-WEB00028947 shows 'Santiago and 1 other', but both payments go to Santiago. The history recipients list is built from payments (positive_payments), not distinct recipients.
  • At 480-767px the history table cramps and dates wrap to three lines ('25 / Jun / 2024'). B's stacked layout up to 767px removes this (QA render: scratch/qa-B-600.png).

10 of 10

Show whose account the money goes to, in lists and at Confirm

Choosing and confirming a payee relies on a nickname alone. Recipient lists are meant to show the masked account under each nickname, but on phones that line is hidden, and on desktop it is #bbb bold 12px (1.9:1). The same list is the picker for trade payments, so on a phone you choose who gets 10,405.95 GBP from 'John' alone. The allocate step says 'Allocate funds to John' and the confirm step shows 'John - INV-2291 Harbour freight, 10,405.95 GBP'. The account holder (Demo Inc.), bank (Bank of England), sort code and account number never appear before Confirm. On phones the title splits around Cancel, and the form sits 5px from the screen edge while the title is inset 28px.

Why it happens

reward-history-page.component.scss (lines 14-41) declares .ct-recipient-details .ct-recipient-account and .ct-recipient-name without a page scope. Component SCSS is bundled globally, so those rules win in every recipient list: #bbb bold, and display:none at 0-479px. In the confirm row, allocate-funds.component.html renders only recipient().nickname and the reference. The recipient object passed in already carries bank_holder_name, bank_name, sort_code, account_number and iban. On phones .transfer-title keeps line-height 3.25, so a wrapped title is 117px tall with Cancel floating between its lines, and .allocating-recipient-container drops to 5px side padding.

Today

Phone recipients list today: flag, icon and nickname. The masked account line is display:none.
Desktop list today: the account line is #bbb bold 12px, about 1.9:1.
Phone allocate step today (trade CT-20240605-343): nickname only, the title split around Cancel, and the form 5px from the edge.
Desktop confirm step today: nickname, reference and amount. No holder, bank or account before Confirm.
Recommended: V2. The confirm step is the last check before money leaves. V2 is the only variant that shows the full destination there, in the format finance users check invoices against, and it includes V1's list fix. V3's compact rows can follow later for clients with long payee books.

V1. Minimal: unhide the digits, add one account line

Size XS

Scope the reward-history rules to their page, so the masked account shows in every list at every width in #556d82 regular (5.4:1). On the confirm row, add one line under the nickname: 'Demo Inc., Bank of England, sort code 10-00-00, account 31510604'. Nothing else moves.

Phone list: the masked account is visible again under each nickname.
Desktop list: the same line in #556d82 regular, 5.4:1.
Confirm step: one added line with the holder, bank, sort code and full account number.
  • Mostly a CSS fix, plus a one-line template addition
  • Restores the last four digits in the payment picker, where the choice is made
  • The final check now shows the full destination account
  • The allocate step still names the payee by nickname only
  • The phone title split and the 5px form edge stay as they are
How it would be built

Nest the reward-history recipient rules under that page's own selector. Then give recipient lists their own name and account styles. The name needs $ct-dark-lighter set explicitly: the leak supplies it today, and without it names fall back to #888 (3.5:1). The account line is 12px $ct-dark-lighter regular, visible at all widths, with no -30px phone offset. In the confirm row, add one line built from recipient().bank_holder_name, bank_name and sort_code/account_number, or iban for IBAN payees.

app/frontend/application/src/app/refer-and-earn/pages/reward-history-page/reward-history-page.component.scssapp/frontend/application/src/app/recipient/pages/manage-recipients-page/manage-recipients-page.component.scssapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.scssapp/frontend/application/src/app/trade/components/allocate-funds/allocate-funds.component.html

V2. Payee card, then a real summary

RecommendedSize S

Lists get V1's fix, plus the account holder whenever it differs from the nickname ('Demo Inc. · Account •••• 0604'). The allocate step shows a Recipient card above the amount: flag, nickname, holder and bank, sort code and masked account, with a Change link back to the picker. The confirm step becomes a summary: 'You are sending 10,405.95 GBP', then To (Demo Inc., saved as John), Bank, Sort code, the full Account number and the Reference. On phones the title stays tight with Cancel beside it, and the form moves to the title's 28px gutter.

Phone list: the holder appears before the masked account, so a friendly nickname can't hide whose account it is.
Phone allocate step (390px): a Recipient card with holder, bank, sort code, masked account and Change. The title sits on one line and the form on the 28px gutter.
Confirm step: the amount first, then holder, bank, sort code, full account number and reference. On phones the rows stack.
  • The payee can be checked at both steps, in full at the riskiest click
  • Reads like a bank transfer review, which finance users already trust
  • Uses data already on the selected recipient, so no API change is needed
  • Adds a card to the allocate step and makes the confirm step about 135px taller
  • Shows full account numbers on screen, which is fine for the payer but worth a quick privacy check
How it would be built

Extend ct-recipient-badge into a payee card (holder, bank, sort code and masked account, plus a Change output that returns to the picker) and render it at the top of ct-allocate-funds. Replace the confirm table with a <dl> summary built from recipient() fields; IBAN payees show IBAN and BIC. In getAccountInfo(), prefix the holder when it differs from the nickname. Phone header: line-height 1.3, at most two lines, Cancel with padding 0 and min-width 0. allocating-recipient-container gets 0 28px padding. Same-currency and market-order allocation use the same ct-allocate-funds, so they inherit all of this. Includes V1's scope fix.

app/frontend/application/src/app/trade/components/allocate-funds/allocate-funds.component.htmlapp/frontend/application/src/app/trade/components/allocate-funds/allocate-funds.component.tsapp/frontend/application/src/app/recipient/components/recipient-badge/recipient-badge.component.htmlapp/frontend/application/src/app/recipient/components/recipient-badge/recipient-badge.component.scssapp/frontend/application/src/app/trade/components/recipient-list/recipient-list.component.tsapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.scssapp/frontend/application/src/app/refer-and-earn/pages/reward-history-page/reward-history-page.component.scss

V3. One compact row everywhere

Size S

Recipient rows shrink to two lines: flag and currency code on the left, the nickname in navy, then 'Demo Inc. · Account •••• 0604', with a chevron. On phones rows drop from about 104px to 63px. The row you pick stays pinned at the top of the allocate step with a Change link. The confirm step shows the same row, now with the full sort code and account number.

Phone list: two-line rows of about 63px (104px today), with holder and masked account on line two.
Phone allocate step: the chosen row pinned in a light blue band with Change.
Confirm step: the same row, now with the full sort code, account number and reference.
  • About 60% more recipients per phone screen, which helps long payee books
  • One pattern from picking to confirming, so the payee looks the same at every step
  • The holder name truncates first; the digits never do
  • Drops the company or individual icon from list rows
  • The confirm step keeps today's table, so the account detail is less prominent than in V2
How it would be built

Rework .list-item-content into a three-column grid: 64px currency, 1fr text, chevron. Line two is a flex row where the holder span truncates and the masked account stays nowrap. Render the selected recipient as the same row at the top of ct-allocate-funds, with a Change output, and unmasked in the confirm row. Includes V1's scope fix.

app/frontend/application/src/app/trade/components/recipient-list/recipient-list.component.htmlapp/frontend/application/src/app/trade/components/recipient-list/recipient-list.component.tsapp/frontend/application/src/app/trade/pages/recipients-allocate-page/recipients-allocate-page.component.scssapp/frontend/application/src/app/recipient/pages/manage-recipients-page/manage-recipients-page.component.scssapp/frontend/application/src/app/trade/components/allocate-funds/allocate-funds.component.htmlapp/frontend/application/src/app/refer-and-earn/pages/reward-history-page/reward-history-page.component.scss

Open questions

  • Should the confirm step show the full account number (V2 does, as the final check) or keep only the last four digits?
  • Should the payee card also appear in the other flows that reuse ct-allocate-funds (same-currency transfers, market orders, rate alerts)? The proposal is yes, since it is the same component.
  • The trade summary strip labels an amount (10,405.95 GBP) 'Recipient account:'. Rename it to 'Recipient gets:'? The strip is shared with the quote flow, so that change sits outside this item.
  • Cancel and Back stay #b8c4d0 (1.8:1) in these renders because that is the global .btn-clear issue raised elsewhere. Fix it once, globally?
  • On phones 'Confirm allocated payment' wraps to two tight lines. Keep that wording or shorten it to 'Confirm payment'?

Also spotted

  • Scoping the reward-history rules alone would make recipient names worse. The leaked rule is also what gives names #556d82 at 20px today; without it, the manage-recipients and allocate styles fall back to #888 at 18px (3.5:1). The fix has to set the name colour in the recipient list itself.
  • Once unhidden on phones, the account line would pick up a -30px phone margin from the manage-recipients-page and recipients-allocate-page styles; the leak's margin-left: 0 masks it today. Drop that offset, or the line slides under the icon.
  • The trade summary strip label 'Recipient account:' sits over the amount to be received (10,405.95 GBP), not an account.
  • On phones any step title that wraps (for example 'Confirm allocated payment') takes 117px, with Cancel floating between the lines, because .transfer-title keeps line-height 3.25.

Also found, not designed this round