Five small things Stevan flagged, 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.
C. Pre-select the country we already know (globe when we don't)
4
1 of 5
Header search: the "Search" placeholder slides on every page load
On every full page load the grey "Search" in the header box fades up out of the pill and rises back in from below over 800 ms. It lands on the same word, so the motion carries no information. Your memory is half right. The rotating examples still exist, but only after you click into the box, and since the June 2025 copy change they read `Search "Search for a recipient"`. They also keep rotating after you type, so about 5 s later an example is drawn over your query. On slower loads there is a second movement: the whole search bar jumps about 205 px to the right when the logo arrives. The slide plays on first visit, on refresh and on any server-rendered page. It does not replay on in-app (hash route) navigation.
Why it happens
1) The slide on load. In app/frontend/application/src/app/search/components/header-search-bar/header-search-bar.component.ts (animations block, lines ~19-30) the trigger is `trigger('slideFade', [transition('* => *', [animate('800ms ease-in-out', keyframes([...]))])])`. In Angular animations the `*` wildcard also matches `void`, so `* => *` fires on `void => 0` when the placeholder span is first inserted, which happens every time the header renders. The keyframes are: opacity 1 at y 0, then opacity 0 at y -20px at 40%, a jump to y +20px at 40.1%, then back to opacity 1 at y 0. That is exactly "slides up, then slides in from the bottom". I verified it live: Angular calls element.animate() on both stacked header copies with duration 800 and ease-in-out, and it still runs under prefers-reduced-motion. The trigger was added in c2a9559b1 (May 2025) for the focus-time rotation. togglePlaceholder(true) bumps animationTrigger through rxjs interval(5000)+startWith(0), so the intended transitions were the increments, not the first render. One-word hotfix: transition(':increment', ...).
2) The focus copy. getRecentSearches() wraps each backend recommendation as `Search "${recommendation}"` (a0e78c130). The backend list in app/controllers/api/v1/search_controller.rb#metadata_for_history changed in df795f108 (June 2025) from example terms ['EUR', '100000'] to sentences ('Search for a recipient', ...). The UI therefore now doubles the word.
3) Text drawn over the query. onKeyDown sets placeholderText = '' but never unsubscribes placeholderSubscription, so the next tick writes an example into the span, which sits on top of the input.
4) The load jump. layout/components/header/header.component.scss sets `.ct-logo .ct-svg-icon` to `content: url(ct-logo-viewbox.min.svg)` with a height but no width. The logo is 0 px wide until the SVG arrives, so the pill paints at x=13 and moves to x=218. Measured over 3 dev reloads, the jump comes 1.0-1.6 s after first paint (the dev server serves the SVG with no-store).
Today
Today at 1440 px, the first 1.5 s after the header renders. Frames are scrubbed from the live 800 ms Angular animation, so the timestamps are exact. "Search" fades up out of the pill by 300 ms, re-enters from below at 400 ms and settles at 800 ms on the same word.The same slide, looped (animated, built from the live animation timeline).Real-time reload (Playwright video at 1x, upscaled). The pill paints at the far left with no logo and no icon, the text slides, the icon pops in, and at 1.04 s the logo lands and the whole bar jumps right. Clip: shots/today-reload.webm.The same reload, animated in real time.Click into the box today and the leftover rotation shows `Search "Search for a recipient"`. The dropdown empty state text is 1.8:1 contrast.Type "John" and pause for 5 s: the next rotation tick draws `Search "Search for an account number"` over the query. Results are stubbed from real dev recipients and transfers.768 px, focused: the bar expands across the header and shows the same doubled copy.480 px: the pill shrinks to 85 px and "Search" spills out into the bell divider (existing bug).
Recommended: B. Yes, remove it. The slide is an accident: Angular's '* => *' wildcard also matches the first render, so an animation built for the rotating examples plays on every full page load. It animates "Search" into "Search" and ignores reduced motion. The rotation it belonged to is broken as well, with doubled copy and text drawn over the query. B removes all of that and keeps the useful part of the original idea: a static hint, visible before anyone clicks, saying you can search by name, amount, currency or trade ID. Pair it with the one-line logo width fix so the bar stops jumping right when the logo loads. D is a good follow-up for the vague, low-contrast dropdown empty state.
A. Static "Search" (minimal fix)
Size XS
Stop both the load slide and the focus rotation, so the box always says Search. The placeholder and the typed text move up to AA contrast.
Same 8 timestamps and crop as today: nothing moves.On focus it stays "Search". The dropdown is unchanged.
Smallest change and a direct answer to the question
Removes the doubled `Search "Search for..."` copy and the text-over-query bug at the same time
Placeholder goes to 4.8:1 and typed text to 11.4:1 (today 1.7:1 and 2.9:1)
Drops the only hint that you can search by amount, currency or trade ID
The darker placeholder is a visible change in every header
How it would be built
TS: delete the animations block and the @angular/animations import. Also delete togglePlaceholder() and its calls, placeholderSubscription, animationTrigger, placeholderOptions, the recommendations loop in getRecentSearches(), and the placeholderText = '' line in onKeyDown. HTML: remove the [@slideFade] span and add placeholder="Search" and aria-label="Search" to the input. A native placeholder hides itself while typing and lines up with the typed text (today they are about 3 px apart). SCSS: input color $ct-dark; ::placeholder { color: $ct-dark-lighter; opacity: 1 }; remove the .placeholder-text block. If you only want the load slide gone today, the one-word hotfix is transition(':increment', ...) instead of '* => *'. That keeps the focus rotation and its bugs.
No motion at all. The placeholder reads "Search by name, amount, currency or trade ID" wherever it fits, and falls back to "Search" when the header is narrow.
1440 px at rest. Same crop as today's frames.1024 px: the hint still fits (it needs a pill of 418 px or more).768 px: the container query falls back to "Search".768 px focused: the bar expands to 510 px and the full hint shows.Real-time reload of B with the logo slot reserved, same crop and timestamps as today. The bar paints in its final place, the text never moves, and the logo just fills its slot. Clip: shots/b-reload.webm.The same reload, animated in real time.
Keeps what the rotating examples were for (showing what search covers) with no animation
Visible before anyone clicks, on every page
Matches what the backend actually searches: recipient names, account numbers and IBANs, exact amounts, currency codes and trade IDs
On tablets the full hint appears once the bar expands on focus
Longer text in the header on every page
"Trade ID" is the term from confirmation emails; the app says "transfer" elsewhere (see open questions)
How it would be built
Same TS removals as A. HTML: make the span static and hide it while typing: `@if (!searchInputControl.value) { <span class="placeholder-text" aria-hidden="true"><span class="ph-long">Search by name, amount, currency or trade ID</span><span class="ph-short">Search</span></span> }`, and add aria-label="Search" to the input. SCSS: `.input-container-animated-label { container-type: inline-size }`, `.ph-short { display: none }`, `@container (max-width: 417px) { .ph-long { display: none } .ph-short { display: inline } }`, plus the placeholder and typed-text colours from A. A container query beats a media query here because the pill width depends on the account switcher. Companion fix for the load jump in header.component.scss: give `.ct-logo .ct-svg-icon` `width: 205px`, and `width: 59px` in the 480-767 symbol-logo breakpoint.
C. Examples only after focus, with a calm crossfade
Size S
The box is a static "Search" on load. After 1.2 s idle in an empty, focused box, the placeholder crossfades through example queries every 3 s and stops the moment you type.
From focus to typing: "Search" fades at 1.1 s, examples change every 3 s, and typing stops the rotation with no overlap.The focus behaviour, looped.
Brings back the rotating examples you remember, without the bugs
Only runs once someone has chosen to search, never on load
The examples teach the less obvious queries (IBAN, exact amount, currency code)
Still motion inside a box you are about to type in, competing with the dropdown
The most code of the four: timer, fade, reduced-motion handling and a backend copy change
How it would be built
Controller: metadata_for_history.recommendations becomes display-ready strings: 'Try a recipient name', 'Try an account number or IBAN', 'Try an amount, like 10,000', 'Try a currency code, like EUR', 'Try a trade ID'. TS: placeholderOptions = result.meta.recommendations (assign rather than push, and drop the `Search "..."` wrapper). Use timer(1200, 3000) instead of interval(5000)+startWith(0). Use transition(':increment') with an opacity-only 360 ms keyframe (1, 0, 1), swapping the text at the midpoint. Unsubscribe on the first non-empty value and in ngOnDestroy, and set [@.disabled] when prefers-reduced-motion is on. Placeholder and typed-text colours as in A. Check the mobile apps before changing the API strings, because meta.recommendations is in the public OpenAPI doc.
The header stays a static "Search". Clicking in shows what you can search for in the dropdown, replacing "Let's find what you are looking for". When there are recent searches, a one-line tip sits under the list.
Focus with no history: three rows plus the 3-character rule. Same crop as today's focus shot.With recent searches: the existing list plus a one-line tip.768 px focused: fits the full-width dropdown.
The header is as calm as A
Explains the scope at the moment of use, using the sidebar's own icons
Replaces a 1.8:1 empty state with AA text (5.4:1 detail, 12.8:1 titles)
Only helps people who already click into search
Makes the empty dropdown taller
How it would be built
Includes A's header changes. HTML: replace the @case ('first-search') block with a "You can search for" title ($ct-dark, 14px bold) and three rows. Each row uses the existing ct-icon-recipients-list, ct-icon-transfer-history or ct-icon-rate-alert-manage in $ct-dark-lightest, a $ct-dark 16px title, and a $ct-dark-lighter 14px detail line: Recipients: Name, account number or IBAN / Transfers: Trade ID, amount, currency or recipient / Market orders and rate alerts: Currency or amount. End with a $ct-blue-grey-lighter rule and "Type at least 3 characters to see results." In @case ('recent-searches') add "You can also search by amount, currency or trade ID." under the list. No new TS logic beyond A.
Is "trade ID" the right term for clients? It is the label in confirmation emails ("Trade ID: CT-20240625-866"), but the app says "transfer" everywhere else. "Reference" is risky because search does not cover the payment references clients type.
Do the mobile apps read meta.recommendations from /api/v1/search/history? If not, A and B can stop using it; C would change those strings.
There is no header search on phones at all: it is display:none below 480 px, so there is no phone render for this item. Is that intended, or should phones get a search entry point?
Ship the logo width fix with this? In production the hashed SVG is probably cached, so the jump mostly hits first visits and slow connections.
Also spotted
Focus copy reads `Search "Search for a recipient"`: getRecentSearches() wraps each backend recommendation in `Search "..."`, and df795f108 (June 2025) changed the backend strings in search_controller.rb#metadata_for_history to full sentences.
Typing does not stop the rotation. onKeyDown clears placeholderText, but placeholderSubscription keeps ticking, so about 5 s later the next example is drawn over the typed query (shots/today-typed-overlap.png).
placeholderOptions only ever grows: getRecentSearches() pushes the 4 recommendations again on init and after every search. placeholderSubscription is also not unsubscribed in ngOnDestroy.
The load slide ignores prefers-reduced-motion. With reducedMotion 'reduce' emulated, both placeholder spans still animate.
Layout shift on load: `.ct-logo .ct-svg-icon` has no width (it uses content: url(svg)), so the header paints with the search pill at x=13 and jumps to x=218 when the SVG arrives, 1.0-1.6 s after first paint in dev.
Contrast: the placeholder #ADBCC9 on #F0F2F3 is 1.7:1 and typed text #7a92a8 is 2.9:1. The dropdown empty state, result subtitles and "Booked On" dates are #b8c4d0 on white at 1.8:1. AA needs 4.5:1.
From 480 to about 540 px the header pill shrinks to 85 px and "Search" overflows it into the bell divider.
The `.header-search-bar-container > input` and `> .ct-icon-item` SCSS rules no longer match anything, because the input and icon moved inside the label in May 2025 (dead CSS, including the tablet width rules).
Currency search only matches transfers that bought the currency (by design, per the comment in app/services/search/trades.rb). "SAR" returns No Results for this dev account although all of its transfers sold SAR. Worth a product check.
The magnifier icon pops in about 0.4 s after the placeholder text on load, because the icon font arrives later (minor).
2 of 5
Modify quote hover on the quote summary bar
Hovering the summary bar at the top of the quote page shows a grey veil with a Modify Quote button. The veil only covers the content column, so it starts halfway across the green step tick and stops short of both ends of the row. The button only exists while you hover, so keyboard users never reach it. Between 480 and 767px the same veil also pops up over the stacked layout, on top of that layout's own MODIFY REQUIREMENTS button.
Why it happens
From 768px up, trade.scss:694-697 pulls every step's .ct-quote-content 19px left into the 40px tracker column, and trade.scss:59-65 makes .ct-quote-content and its .inner-container position: relative. The veil (.ct-trade-content-overlay, overview.component.scss:9-16) is absolutely positioned at 100% of that inner container. At 1440px it therefore starts at x=443 while the tick spans 427-457, covering only its right half, and it ends at the 800px column (x=1222) instead of the row edge (x=1439). The hover listeners sit on .ct-mouseevent-wrapper inside the same column (overview.component.html:11-12) and toggle showModify on both enter and leave. The button is rendered with @if (showModify) (overview.component.html:62), and the veil is only hidden below 480px (overview.component.scss:32-34).
Today
Today, hovering the summary at 1440px: the veil starts in the middle of the green tick and stops at the content column, well short of the row endClose-up at 1440px: the veil's left edge cuts the tick in halfToday at rest, for comparing B and CToday at 768px: same half-covered tickToday at 600px: the veil also covers the stacked layout, on top of its own MODIFY REQUIREMENTS button
Recommended: C. It removes the reason for the hover in the first place. Modify becomes visible to everyone, works with touch and keyboard, never hides the amounts, and matches phones, where the action is already always visible. If you would rather keep the hover, A is the exact end-to-end fix at almost no cost.
A. Full-row veil (the literal fix)
Size XS
The same grey veil and centred Modify Quote button, but the veil now covers the whole summary row from the sidebar edge to the window edge, tick included. It also no longer appears over the stacked layout below 768px.
A, hover at 1440px: the veil covers the whole row, sidebar edge to window edge, tick includedClose-up: the tick is fully coveredA, hover at 768pxA at 600px: no veil over the stacked layout any more
Exactly the end-to-end hover you described, with the tick fully covered.
Tiny change: three CSS rules plus moving the two hover listeners onto the row.
Also removes the double Modify button between 480 and 767px.
Still hover-only. Keyboard users cannot reach Modify, and touch users need a first tap to reveal it.
The veil still washes out the amounts while you are pointing at them.
How it would be built
SCSS, scoped to ct-overview only: give the root .transfer-container position: relative, and set .ct-quote-content and its .inner-container to position: static. The existing inset-0 overlay then sizes to the full row. Also change the overlay's hide breakpoint from $max-phone to $max-tablet-sm. HTML: move (mouseenter) and (mouseleave) from .ct-mouseevent-wrapper to the root div, so the tick and the row ends trigger it too. TS: set showModify to true on enter and false on leave instead of toggling, so it cannot drift.
Hovering tints the whole row end to end in the light brand tint ($ct-highlight-bg) instead of washing it out, so the amounts stay readable. A Modify button fades in at the row end, and clicking anywhere on the row modifies the quote.
B, hover at 1440px: the row tints end to end behind the tick, figures stay readable, Modify appears at the row endB at rest: as clean as today, with the summary sitting slightly leftB, hover at 768px: bank icons hidden to make room
The hover state runs end to end behind the tick without hiding any figures.
Bigger target: the whole row is clickable, and the button shows on keyboard focus.
As clean as today when you are not hovering.
You still only discover Modify by hovering.
The summary shifts about 56px left to leave room, and the decorative bank icons are hidden between 768 and 991px.
How it would be built
HTML: delete the @if (showModify) overlay and the mouse handlers. Append a button (ct-btn btn-primary lowercase-btn small-btn, (click)="onModify()") with ct-icon-pencil, the label 'Modify' and aria-label 'Modify quote' at the end of .header-container. Wrap it in @if (canModify()), because trade-confirmation reuses ct-overview with canModify=false. Also put (click)="onModify()" on the row. SCSS: tint the row on :hover under @media (hover: hover) and (min-width: $min-tablet-lg). Position the button absolutely, 28px from the right, with opacity 0 until the row is hovered or the button has :focus-visible. Give .ct-mouseevent-wrapper 112px of right padding, and hide .section-payment > img between $min-tablet-lg and $max-tablet-lg. TS: remove showModify and triggerShowModify.
A quiet outline Modify button sits at the end of the summary row all the time, so no hover is needed and the row itself stays still. Phones keep their full-width button, restyled to the same outline and labelled Modify quote.
C at rest, 1440px: Modify always visible at the row endC, hovering the button: the border and tint change, the row itself stays stillC at 768px: bank icons hidden to make roomC on a 390px phone: the existing full-width button in the same outline style, labelled Modify quote
You can find it without hovering, and it works with touch and keyboard.
Never hides the figures, so the summary goes back to being read-only information.
Desktop now matches phones, where the action is already always visible.
Replaces the row hover you asked about instead of fixing it. A light row tint could be kept on top if you like it.
The same 56px shift and hidden bank icons at 768 to 991px as B.
How it would be built
HTML: delete the overlay block and the mouse handlers. Append a button (class ct-overview-modify, (click)="onModify()", aria-label 'Modify quote') with ct-icon-pencil and the label 'Modify' to .header-container, inside @if (canModify()). Change the phone button's text to the pencil icon plus 'Modify quote'. SCSS: white button with a 1px $ct-blue-grey-light border, 5px radius, 14px bold $ct-blue text (7.3:1) and a $ct-highlight icon. On hover, a $ct-highlight border on $ct-highlight-bg; give it a visible :focus-visible ring. Give .ct-mouseevent-wrapper 112px of right padding and hide the bank icons at 768-991px. Below 768px, restyle .section-back-to-create .ct-md-button to the same outline with 16px of space under it. TS: remove showModify and triggerShowModify.
Keep a hover pattern (A or B), or make Modify always visible (C)?
One label for the action? Today desktop says Modify Quote and phones say MODIFY REQUIREMENTS. The variants use Modify on desktop and Modify quote on phones.
OK to hide the decorative bank icons between 768 and 991px to make room for the button (B and C)?
Also spotted
Between 480 and 767px the hover veil still renders over the stacked summary, on top of its own full-width MODIFY REQUIREMENTS button. overview.component.scss:32-34 only hides .ct-trade-content-overlay below 480px (see shots/i1-today-hover-sm.png).
The desktop Modify Quote button is only rendered while hovered (@if (showModify), overview.component.html:62), so keyboard users cannot reach it and touch screens need an extra tap.
The same action has two labels: Modify Quote in the desktop hover and MODIFY REQUIREMENTS below 768px.
The summary labels (YOUR ACCOUNT, MID MARKET, RECIPIENT ACCOUNT:) are 11px uppercase #1691c6 on #f8f8f8, which is 3.35:1, below AA 4.5:1. RECIPIENT ACCOUNT: also has a colon that YOUR ACCOUNT does not.
Spotted on the way in: make-a-transfer-page.component.ts:288 sets datepickerMinDate to new Date(undefined), an Invalid Date, when the pairs endpoint has no delivery_dates entry for the chosen pair. That makes past dates selectable. Seen in dev with GBP/USD; production data may always include the entry.
3 of 5
Expired quotes state: Refresh quotes and Set a rate alert
When the quotes stop auto-refreshing, the Confirm Transfer step swaps the quote list for an illustration, a small Refresh quotes link, one long sentence about rate alerts and a SET A RATE ALERT button. Nothing says the quotes have expired. Refresh, the action most people came back for, is the weakest element on screen. The rate alert link also loses the currency pair, and on phones the illustration runs into the step heading.
Why it happens
When it shows: quote-book-page.component.ts starts each quote window at counter = 16 (line 34), with refreshCount = 5 (line 38). Each time the counter reaches 0, onTimeout() silently re-quotes while refreshCount > 0. Otherwise it stops the timer and sets showRequoteMessage = true (line 102), which hides the quote list and shows this block (html:29-53). Arriving from Make a Transfer uses the POSTed quotes and costs no refresh. So the state appears after the first quote plus 5 silent re-quotes, about 1m40s without booking (measured at 100s in dev with the real timer). Refresh quotes (onRequote, line 154) only resets refreshCount to 3, so the state returns about 50s later. Reloading /quotes/:id spends one refresh up front, so it appears after about 85s. Why it looks plain: the markup has no heading, and the hierarchy is inverted, with refresh as a 14px #1691c6 link and the rate alert as the only button. The illustration is pulled up with top: -25px (quote-book-page.component.scss:592-595). The margin: 0 -28px on .ct-requote-container (quote-book-page.component.scss:258-260, trade.scss:699-701) shifts the whole block 28px left of the column centre.
Today
Today at 1440px, after the auto-refreshes run outToday at 768pxToday at 390px: the illustration runs into the CONFIRM TRANSFER heading
Recommended: C. It answers 'a bit plain' with structure rather than decoration. It says what happened, puts refresh (what most people came back for) on the primary button, and keeps rate alerts as an equal, explained choice instead of demoting them. It reuses the approved notification tray style. B is the lighter option if you are happy for rate alerts to become a text link.
A. Tidy up: title, spacing, clearer copy
Size XS
The same pieces and emphasis as today, plus a navy title (Your quotes have expired), a proper refresh icon, a hairline between the two actions and a shorter rate alert line. The illustration no longer overlaps the heading, and the block is centred under it.
A at 1440pxA at 390px: no more clash with the heading
Smallest change: one title, new copy, and spacing and colour fixes.
Says what happened, and fixes the illustration clash on phones.
Keeps the rate alert as the main button, as today.
Refresh, probably the most wanted action, is still a text link.
Still reads as a stack of centred lines.
How it would be built
HTML (lines 29-53): add <h4 class="requote-title">Your quotes have expired</h4> after the illustration, and swap ct-icon-make-a-transfer for ct-icon-refresh. The message becomes 'Waiting for a better rate? We will let you know when {{ sellCurrency }}/{{ buyCurrency }} reaches your target.' SCSS: drop top: -25px and size the illustration to 96x88. Style the title 18px bold $ct-dark, the link $ct-blue (7.3:1), and the message $ct-dark-lighter with a 1px $ct-blue-grey-lighter top border. Remove the margin: 0 -28px on .ct-requote-container here and in trade.scss:699-701. TS: set sellCurrency and buyCurrency from quote() in ngOnInit, which also fixes the rate alert prefill.
One short column: an hourglass icon in a tinted circle, the title, one line on why, a primary REFRESH QUOTES button, and 'Waiting for a better rate? Set a rate alert' as a text link underneath.
B at 1440pxB at 390px
The likely next step becomes the one clear button.
Short and calm: icon, title, one sentence, button, link.
The tinted icon circle matches the approved notification tray.
Rate alerts drop to a text link, which may cost sign-ups.
Loses the bell illustration.
How it would be built
HTML: replace the block with an icon circle (ct-icon-expired), the title and a reason line. Then add a primary button (ct-btn btn-primary, (click)="onRequote()") with ct-icon-refresh, and a routerLink to /rate-alerts with {sc, bc}. SCSS: a 56px $ct-highlight-bg circle, an 18px navy title, 14px $ct-dark-lighter body and an underlined $ct-blue link. Scope the button so the global .ct-requote-container button rule (assets/styles/pages/quote-expire-rate-alert.scss:56) does not shrink it to a 14px transparent link. Remove the -28px margins. TS: assign sellCurrency and buyCurrency.
A white card says the quotes have expired and why, then offers two side-by-side choices. Each has an icon, a heading, one line and a button: Ready to book? (REFRESH QUOTES, primary) and Waiting for a better rate? (SET A RATE ALERT, outline). On phones the options stack with the icons inline.
C at 1440pxC at 768pxC at 390px: options stack with the icons inline; the second option needs a short scroll
Explains the state and gives each path a reason.
Refresh gets the primary button, while rate alerts stay a first-class option.
The white card, navy titles and tinted icon circles match the approved notification tray.
Biggest change of the three: about 70px taller on desktop and 180px taller on phones.
Loses the bell illustration.
How it would be built
HTML: replace the block with a card: a header (title and reason) above a two-column options grid. One option is refresh (button, onRequote); the other is the rate alert (routerLink to /rate-alerts with {sc, bc}). The body copy uses quote().amount, side and the pair through the amountCurrency pipe. SCSS: a white card with a 1px $ct-blue-grey-lighter border and 8px radius, and a 1px divider between the columns. Use 44px icon circles ($ct-highlight-bg for refresh, $ct-action-alt at 14% for the alert), and an outline button with a $ct-highlight border and $ct-blue text. Below $min-tablet-lg the grid becomes one column, with inline icons and full-width buttons. Scope the buttons past the global .ct-requote-container button rule, and remove the -28px margins. TS: assign sellCurrency and buyCurrency. The sibling error state (showErrorMessage, html:56-77) should get the same card in the same PR.
Is the rate alert meant to be the main call to action here, as a growth goal? That decides between A (keeps it primary), B (demotes it) and C (makes both first-class).
Keep the bell-and-chart illustration (A), or move to the tinted icon circles from the notification tray (B and C)?
The page stops auto-refreshing after 5 silent re-quotes (about 1m40s), but after Refresh quotes it only does 3 (about 50s). Is that intended, or should they match?
Also spotted
SET A RATE ALERT never pre-fills the currency pair. The template passes {sc: sellCurrency, bc: buyCurrency} (quote-book-page.component.html:47), but quote-book-page.component.ts declares those fields (lines 51-52) and never assigns them. The link renders as #/rate-alerts with no params (confirmed in the rendered DOM), even though create-rate-alert.component.ts:334 reads sc and bc.
On phones the illustration overlaps the CONFIRM TRANSFER heading, because .set-rate-alert-image has top: -25px (quote-book-page.component.scss:592-595). See shots/i2-today-phone.png.
From 480px up the block sits 28px left of the column centre, because .ct-requote-container gets margin: 0 -28px (quote-book-page.component.scss:258-260, trade.scss:699-701).
Between 480 and 767px the quote list slides 19px under the collapsed sidebar, so 'Quote expires in' renders as 'ote expires in'. quote-book-page.component.scss:253-256 applies .ct-quote-content { margin-left: -19px } from 480px, but the tracker column it compensates for is hidden below 768px (trade.scss:850-853).
The Refresh quotes link is 14px bold #1691c6 on white, which is 3.56:1, below AA. It also uses the transfer icon (ct-icon-make-a-transfer) rather than ct-icon-refresh.
App-wide: white text on the primary button colour #1691c6 is 3.56:1, below AA for 16px text. The same hue at #137daa gives 4.62:1. The variants keep today's button colour for consistency; all new text they add passes AA.
4 of 5
Funds on Balance: refresh and last-updated line
The 'Refresh Last updated: 20:57 (BST) on 29 Sep 2026' line is a 12px grey strip wedged between the page title and the All Balances filter. It sits 36px below the title but touches the filter, so it belongs to neither and reads like a leftover label. The action comes before the status, the timestamp is long and absolute when the real question is how fresh the numbers are, and the text fails AA contrast (3.3:1 grey, 3.2:1 blue link). Clicking Refresh changes nothing on screen until every partner has answered, which can take several seconds. On phones the whole line is hidden, Refresh included.
Why it happens
Template: app/frontend/application/src/app/balances/pages/balances-page/balances-page.component.html:6-11. Refresh is an <a (click)='getBalances(true)'> with no href, nested inside <span class='last-updated-date'> next to 'Last updated: {{ lastUpdatedDate | ctDateTime }}'. Styles: balances-page.component.scss:147-166 (.last-updated) use 12px bold $ct-grey-5 #888 and $ct-highlight-text #1695ca on the #f8f8f8 page; the phone breakpoint sets .last-updated-date { display: none }, and because the link sits inside that span, Refresh disappears too. The shared ct-page-title adds margin-bottom: 36px (shared/components/text/page-title/page-title.component.scss), pushing the line away from the title. Behaviour: balances-page.component.ts:64-66 subscribes to balanceService.awaitBalancesData(true) again on every click, with no refreshing flag and no error callback. trade/services/balance.service.ts tracks isFetchingBalancesData but nothing shows it, and on failure calls balancesDataSubject.error() (line 36). Server: api/v1/balances_controller.rb#index runs LoadBalances#perform(force_refresh: true) (app/services/load_balances.rb:11), which busts the 1-hour per-user Rails cache and calls each verified trading broker account's partner API one after another via BrokerAccount#refresh_broker_balances (app/models/broker_account.rb:145, which also writes balance rows). That sequential round trip is the wait users sit through with no feedback.
Today
Today, desktop 1440: tiny grey line between title and filter, action before status, 3.3:1 contrastToday, 3 seconds into a refresh with the partner call still running: nothing on screen changesToday, tablet 768Today, phone 390: the line and the Refresh link are hidden, so phone users cannot refresh
Recommended: B. It fixes every real problem (contrast, keyboard access, the missing phone refresh, zero feedback) and makes the header look deliberate without adding a box: title on the left, freshness and the action on the right. It also removes a row, so balances sit 30px higher. Refreshing, just-updated and error states all live in the same small slot, so it copes with slow partner calls without C's extra chrome. If refreshes regularly take more than about 5 seconds in production, borrow C's thin progress line under the header.
A. Tidy subtitle
Size XS
Keep the line in the same slot but turn it into the title's subtitle: 'Updated 12 minutes ago' first, then a real Refresh button, aligned with the title text, in AA colours and visible on phones.
A idle: subtitle aligned with the title text, status first, Refresh as a real buttonA refreshing: spinner and 'Refreshing...', button disabledA tablet 768A phone 390: status and Refresh now visible
Smallest change: one template block and one SCSS block, nothing else on the page moves.
Fixes contrast (5.1:1 and 6.9:1), keyboard access and the missing phone refresh in one go.
Relative time answers 'how fresh is this?' at a glance; the exact time stays in the tooltip.
Still a line of text, so it may still read as a bit plain.
Refreshing feedback is only a small spinner and label, easy to miss during a slow partner call.
How it would be built
Replace the .last-updated block with a div.balances-meta (aria-live='polite') holding a span 'Updated {{ lastUpdatedDate | timeAgo }}' whose title binding is 'Last updated ' + (lastUpdatedDate | ctDateTime) and <button type='button' [disabled]='balanceService.isFetchingBalancesData' (click)='getBalances(true)'>. SCSS: 14px $ct-dark-lighter status, button label $ct-blue #195c7d with the $ct-highlight icon, underline on hover, visible focus ring; padding-left 49px from 480px up so it lines up with the title text; override the title margin to 6px on this page only; delete the phone display: none. While refreshing, swap the icon for a 14px CSS ring spinner and the label for 'Refreshing...'. timeAgo is a pure pipe, so give it an optional second arg fed by a now signal (every 30-60s) or the text freezes. Import TimeAgoPipe in the component.
Move freshness and the action into the empty right side of the title row: 'Updated 12 minutes ago' plus a small outlined Refresh button. The line under the title goes away, so the filter and balances sit 30px higher; on phones the status wraps into its own row.
B idle: status and Refresh on the right of the title row, filter moves up 30pxB refreshing: spinner and 'Refreshing balances...', button disabledB just refreshed: tick for about 3 seconds, then back to plain textB refresh failed: inline message, amounts kept, button becomes Try againB tablet 768: fits on one rowB error at tablet 768: the longer message wraps to its own rowB phone 390: status wraps under the title with the button on the rightB phone 390, refreshingB phone 390, error: message wraps, button stays on one line
Looks deliberate: title on the left, page status and action on the right, the standard page-header pattern.
Removes a row, so the filter and balances move up 30px on desktop and tablet.
One small slot carries every state: spinner with 'Refreshing balances...', a tick for 'Updated just now', and an inline error with Try again.
Touches the header structure (wraps the shared ct-page-title in a flex row), a little more than A.
The longer error message drops onto its own row at tablet width, so the header grows in that state.
How it would be built
HTML: wrap <ct-page-title> and a new div.balances-status (aria-live='polite') in div.balances-header; delete .last-updated. Status via @if: refreshing (ring spinner + 'Refreshing balances...'), justUpdated (ct-icon-tick-circle-full in $ct-alert + 'Updated just now', cleared after ~3s), error (ct-icon-warning-icon in $ct-warning + navy 'Couldn't refresh. Amounts shown are from 20:57', button label 'Try again'), else 'Updated {{ timeAgo }}' with the exact ctDateTime as title. Button: 34px, white, 1px $ct-blue-grey-light border, 5px radius (same family as .ct-btn.btn-info), navy label, $ct-highlight icon, $ct-highlight-bg hover, nowrap, focus-visible ring. SCSS: .balances-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px 24px; margin-bottom: 30px } and page-title-container margin 0 inside it; under 480px the status goes full width with space-between. TS: refreshing / refreshError / justUpdated signals, one subscription in ngOnInit with takeUntilDestroyed, refresh() calls balanceService.refreshBalancesData(true) instead of re-subscribing. Service: stop calling balancesDataSubject.error() (surface failures separately) so a failed refresh can be retried, and send X-Hide-Generic-Error on the refresh call so the inline message replaces the generic error toast. Same timeAgo ticking note as A.
Replace the line with a slim white bar that says where the numbers come from and carries four states: idle ('Last checked with your partners 12 minutes ago'), refreshing (spinner, message and a progress line), done, and error with Try again.
C idle: white status bar explains where the numbers come fromC refreshing: spinner, message and an indeterminate progress lineC done: confirmation, returns to idle after a few secondsC error: tinted bar, amounts kept, Try againC tablet 768C phone 390: icon-only button keeps the bar compactC phone 390, refreshing
Most explicit: tells users CT is checking their partners and that it can take a few seconds.
The moving progress line reassures during slow partner calls, so nobody clicks twice.
The error state is hard to miss and keeps the previous figures in context.
Adds a bordered box that competes with the balance cards and pushes the list down about 34px.
Most wiring (state machine, timers, service fix): size M for what is a line of status text.
How it would be built
Add div.balances-status-bar (role='status') between the title and the filter with a status signal ('idle' | 'refreshing' | 'done' | 'error') and @switch for icon, message and button; 'done' returns to idle after ~4s. White bar, 1px $ct-highlight-grey border to match the cards, error state on $light-red with a soft red border and navy text. Indeterminate 3px progress bar ($ct-highlight on $ct-blue-grey-lighter) plus a prefers-reduced-motion fallback that stops the animation. Under 480px the button becomes a 36px icon-only button with aria-label 'Refresh balances'. Amounts are deliberately not dimmed while refreshing, because dimming would drop them below AA. Needs the same service fix and single subscription as B.
How long does a refresh take in production (p50/p95)? It calls every partner in sequence; if it is often over about 5 seconds, C's progress line is worth adding to B.
Relative time with the exact time in a tooltip, or show both ('Updated 12 minutes ago, 20:57 BST')? Tooltips do not exist on touch devices.
Should the page refresh automatically when cached balances are older than, say, 30 minutes on open? Today the server cache can be up to an hour old.
Apply the same treatment to the 'Last updated / Refresh' line on the New Transfer balance dropdown (shared/components/form/currency-balance-dropdown) so both match?
Partner failures currently show as 0.00. Do we want a backend change so the UI can say which partner could not be reached?
Also spotted
Phones (under 480px) hide the whole line including the Refresh link, because the link sits inside .last-updated-date, which the phone breakpoint sets to display: none (balances-page.component.scss:163-164). Phone users cannot refresh or see how old the balances are.
Refresh is an <a> with no href (balances-page.component.html:7), so it cannot be reached by keyboard and has no button semantics.
No feedback while refreshing: the request calls every partner API in sequence (LoadBalances then BrokerAccount#refresh_broker_balances) and nothing on screen changes until all of them answer.
BalanceService calls balancesDataSubject.error() on failure (trade/services/balance.service.ts:36). The ReplaySubject is then dead, so every later Refresh silently does nothing until a full page reload, and because the service is shared the New Transfer balance dropdown stops updating too. If the first load fails, the Lottie loader spins forever because balancesLoaded is never set.
Subscription leak: every Refresh click and every visit to the page adds another subscribe() on the shared ReplaySubject that is never unsubscribed (balances-page.component.ts:64-66), so handlers pile up.
Partner errors are shown as zero: each TradingApis client's balance() has a bare rescue returning nil, refresh_broker_balances then returns {}, and LoadBalances shows 0.00 for that partner, cached for an hour under a fresh 'Last updated' time. An outage looks like an empty balance.
The currency name (Euro, British Pound) never renders under the code in group headers or dropdown options: the template reads currencies[balanceGroup.key] (html:37 and :65) but /api/v1/currencies/supported returns an array.
Contrast below AA: 'Last updated' in #888 at 12px on the #f8f8f8 page is 3.3:1; the Refresh link and the 'TOTAL BALANCE - N ACCOUNTS' labels in #1695ca are 3.2:1.
Global .ct-btn:focus { outline: 0 } in assets/styles/components/buttons.scss removes the focus ring from every CT button.
On phones the group total is right-aligned inside a centred column, so '1,521.62 EUR' sits off-centre under the centred flag and currency code.
5 of 5
Phone country code: default state
When no country is chosen, the phone field shows a grey rectangle and a chevron. It looks like a flag that failed to load, and nothing tells you a code goes there. The same grey block appears on every screen that uses the shared phone field: add recipient via email, recipient bank details, edit profile, authorised users and registration. It also hides a real data bug. If someone types a number without picking a code, the number stays on screen but is never saved, so the email request goes out with no phone number.
Why it happens
intl-tel-input 28 already has its own 'no country yet' state: it adds iti__globe and a globe SVG to the flag element. phone-input-field.component.ts setInputStyles() (line 187, class rewrite at line 200) rewrites every flag element to CT's sprite classes ('cflag cf-<iso>'). The globe has no ISO code, so it becomes 'cflag cf-undefined'. assets/styles/utils/flags.scss line 984 paints .cf-undefined with a solid $ct-blue-grey (#b8c4d0, 1.8:1 on white), which covers the library's globe. Only registration passes a default ([country]="client.country" in account-details-page.component.html line 151). The via-email form, the recipient bank form (which already knows bank_account_country), edit profile and edit authorised user pass nothing, so they all start with no country. The silent drop happens because onInputChange() (phone-input-field.component.ts line 87) emits '' whenever there is no selected dial code.
Today
Today, desktop: grey block and chevron when no country is setToday, list open: blue block around the grey swatch. Åland Islands also shows the wrong flag here (see bugs).Today: a number typed with no code stays on screen, but the form saves it as emptyToday at 390pxSame grey block on Settings, Edit profile when no phone is saved
Recommended: C. C answers both halves of your point. When we genuinely don't know the recipient's country, the globe says 'not chosen yet' instead of looking broken. When we do know (the transfer currency, the bank country, the client's own country), it saves a step and matches registration, which already pre-fills. If the ZAR in your screenshot came from a transfer (the pc link), C would have shown South Africa +27 right there. It builds on the XS globe fix in the shared component, so all five phone fields behave the same, and it stays restrained: same Lato, same tokens, a flag you can change. Ship it with the silent-drop hint, because that bug loses data whichever variant you pick. If you want an even smaller first step, A on its own is the XS version.
A. Globe icon
Size XS
Swap the grey block for a small globe from the CT icon font, in muted grey. Everything else stays as today: same chevron, spacing and behaviour.
Globe instead of the grey block. Same spacing and chevron.
Reads as 'no country picked yet' instead of a broken flag, the same idea the phone library uses
CSS only, in one shared file, so all five phone fields get it at once
Uses the existing ct-icon-globe glyph and $ct-dark-lightest, which is 3.2:1 on white (enough for an icon)
Nothing on screen says a code is needed, so people can still type a local number and lose it
Ignores the country CT already knows (bank country, the client's own country, a ZAR transfer)
How it would be built
Add a no-country rule: `.iti__flag.iti__globe.cflag { background: none; display: flex; align-items: center; justify-content: center; }` (it outranks .cf-undefined), hide the library's `.iti__globe-svg`, and draw the ct-icon-globe glyph (`content: '\e939'; font-family: 'currencytransfer-icons'`) at 20px in $ct-dark-lightest via ::before. Optionally, stop setInputStyles() from adding cf-undefined when there is no ISO code.
Turn the picker into a labelled segment behind a thin divider: globe, the word 'Code' and a chevron. After picking a country, the same segment shows the flag and +27.
No code picked: globe, 'Code' and chevron in a divided segmentAfter picking South Africa: flag and +27 in the same segment. The number starts in the same place.
Says plainly that a code goes here, which should cut down on numbers typed without one
+27 moves inside the picker, so hover and open states cover all of it (fixes today's half-covered highlight)
The segment has a fixed width, so the number doesn't shift when a code is picked
Visibly changes every phone field in the app, including profile and registration, not just this form
Still starts empty even when CT knows the country
How it would be built
SCSS: make .iti__selected-country auto width (min-width 132px) with a 1px $ct-blue-grey-light right border, and move the hover background onto it. Set .iti__selected-country-primary to display: contents, order the flag, dial code and arrow, and give the arrow margin-left: auto. Add `.iti__selected-dial-code:empty::before { content: 'Code' }` in $ct-dark-lighter (5.4:1). Use the globe from A, and set --iti-icon-color to $ct-dark-lightest so the chevron clears 3:1. TS: setInputStyles() adds the dial-code width on top of the button width, but the button now contains the dial code. Pad by the button alone, e.g. `button.offsetWidth + 12`.
C. Pre-select the country we already know (globe when we don't)
RecommendedSize M
Pass the best-known country into the field's existing [country] input, as registration already does, and show A's globe only when there's no good signal. On this form, arriving from a ZAR transfer (or picking ZAR) pre-selects South Africa +27. EUR, USD and other shared currencies stay on the globe.
Opened from a ZAR transfer (pc=ZAR): South Africa +27 pre-selected, matching the currency belowEUR is used in many countries, so no guess: globeList open: scrolls to South Africa. The light tint covers flag, chevron and +27 (white on today's blue would only be 3.4:1).Digits typed with no code: keep them and say what's missing, instead of silently dropping the numberPhone, 390pxPhone, list open. It opens upward because the field sits low on an 844px screen (the library's own rule).Same rule on Edit profile: the client's own country (GB stubbed, since this dev user has none)
Most people never need the list: paying out ZAR usually means a South African number, and the flag makes the guess visible and easy to change
One rule everywhere: bank country on the bank-details form, the client's own country on profile and authorised users, the payout currency here, and the globe otherwise. Registration already works this way.
Includes A's globe fix, and the hover and open tint now cover the flag, chevron and +27 together
A guess can be wrong (for example a South African payee with a UK mobile). The flag is visible, but a wrong code still looks valid.
Preferred Currency sits below the phone field, so without a pc link the default only helps if they pick the currency first or come back up
How it would be built
SCSS: use the globe from A. Move the hover and open backgrounds from .iti__selected-country-primary to .iti__selected-country (auto width, min 86px, hide an empty dial code) so they cover +27. Use $ct-highlight-hover on hover, and $ct-highlight-background with a $ct-highlight chevron when open. TS: setInputStyles() pads by the button width. setSelectedCountry() already only applies a country while the number is empty; add a flag so a manual pick is never overwritten. Via-email form: bind [country] to a getter that maps model.currency through a small single-country map (GBP GB, ZAR ZA, AUD AU, NZD NZ, CAD CA, CHF CH, JPY JP, SGD SG, HKD HK, AED AE and so on) and returns nothing for EUR, USD, XOF, XAF and XCD. The pc param already sets model.currency on load. Recipient bank form: [country]="model().bank_account_country". Edit profile and authorised user: [country] = the client's country from UserProfileService (data.country), the same as registration. Pair with the silent-drop fix: if digits are typed with no code, keep them and show 'Choose a country code so we can save this number.' in .input-help-text style ($ct-dark-lighter, 14px). The globe part alone is XS; the whole package is M because it touches several forms and their specs.
Drop the separate +code box. People type or paste the whole number (e.g. +27 82 123 4567) and the flag switches as soon as the code is recognised. The list is still there for anyone who prefers it.
Globe plus an example that includes the code (Ofcom's drama range, so clearly fake)After typing +27 82 123 4567: the flag switches to South Africa
Matches how people copy numbers from contacts and email signatures, so no list is needed
Behaves the same on every screen with no per-form wiring
Pasting a +27 number already auto-detects today, so the library handles this well
Anyone who types a local number (082 ...) still ends up with no country, so it needs the same hint and is arguably easier to get wrong
Changes the look of all five phone fields and how saved numbers display. The spaced formatting shown needs the library's utils script loaded.
How it would be built
intlTelInput options: separateDialCode: false and nationalMode: false (so saved numbers show as +27 ...), keeping countrySearch. Placeholder 'e.g. +44 7700 900123'. onInputChange() and removeCountryCode() already strip a leading +code. For as-you-type spacing, add loadUtils: () => import('intl-tel-input/utils') (a lazy chunk). Use the globe from A, plus the same no-code hint as C. Remove the dial-code padding logic from setInputStyles().
app/frontend/application/src/app/shared/components/phone-input-field/phone-input-field.component.tsapp/frontend/application/src/app/shared/components/phone-input-field/phone-input-field.component.scssthe five templates that set placeholder="1234 5678" (or a new default placeholder in the component)
Open questions
On the via-email form, should Preferred Currency move above Phone Number so the code is set before they reach the phone field? It's a form-order change, so I left it out.
OK to use the client's own country on edit profile and authorised users, and the bank country on the bank-details recipient form, the way registration already does?
I suggest no guess for USD, since it's used for payouts to many countries. Would you rather default USD to +1?
When someone types a number with no code and presses Send, should we block with a field error, or send without the phone (today's behaviour, but now with the visible hint)?
Also spotted
Silent data loss: a phone number typed with no country code stays visible but is never saved. onInputChange() emits '' when there's no dial code (phone-input-field.component.ts line 87). Verified: after typing 082 123 4567, model.telephone === '' on the via-email form, so the request goes out without a phone.
Typing in the country search flips the list above the field. setDropdownStyles() reads Boolean(dropdown.style.bottom) (phone-input-field.component.ts line 217), but its own first run sets bottom to 'auto', which is truthy. Any later call (a search keystroke, a window resize) then moves the list up over the Email and Reference Name fields. Verified on desktop by typing one letter; on phones it flips straight away. Fix: treat 'auto' as 'not up', or store the direction when the list opens.
Half-covered highlight, the same pattern as the Modify button: with a country picked, the hover tint and the blue open state cover only the flag and chevron, while +27 sits outside. .iti__selected-country-primary is fixed at 86px (phone-input-field.component.scss line 93) and the dial code is its sibling.
Seven countries in the phone list have no sprite class: ax Åland Islands, cx Christmas Island, cc Cocos Islands, gg Guernsey, yt Mayotte, sj Svalbard and Jan Mayen, eh Western Sahara. They all fall back to background-position 0 0 and show the Ascension Island flag (visible for Åland in today-open.png). Guernsey is the one CT clients are most likely to pick.
Preferred Currency is 44px tall with the placeholder but 47px once a currency is picked (the flag makes it taller), so the Back and Send buttons jump about 3px. The other inputs are 47px.
When the form opens from a transfer with pc=ZAR, Preferred Currency is locked (ng-select disabled) but looks exactly like an editable field.
At 390px, the SEND EMAIL REQUEST label is 174px wide in a 175px button with 28px side padding, so the text runs to the button edges.
Console error on a cold load of Add recipient: recipient-form.component.ts line 362 reads response.data.permissions from UserProfileService.awaitData(), whose BehaviorSubject first emits undefined. add-recipient-via-email-form.component.ts line 52 has the same unguarded read.
Field error text ($ct-warning #e53935 at 13px) is 4.2:1 on white, just under AA.