Hands-on · Case 03 of 3

Bringing a desktop‑era platform to phones and tablets.

“Responsive” is not the same as making sense on a phone. The brief was “improve the mobile view”; what it turned into was writing the mobile layer the product never had, starting with the fact that the TypeScript called it mobile up to 959.98px and the CSS called it mobile up to 767px. Two parts of the same app were solving for different products.

The platform is thirteen years old and was built for desktop. Mobile support had been added reactively over the years: a breakpoint here, an override there, by many people solving their own immediate problem. Nobody had ever sat down and decided what mobile should be.

The result wasn’t a product that looked broken on phones. It looked almost right, which is harder to argue about. Everyone agreed mobile “needed work” and nobody could point at the thing to fix.

There was no breakpoint system to fix

I found the problem as a contradiction between two numbers. The app decided “is this a small screen?” in TypeScript, at 959.98px. The stylesheets decided the same question in CSS media queries, at 767px. In the gap between them the app rendered its mobile component structure while serving desktop styling, because no mobile media query ever matched.

That gap is iPad portrait. Every iPad in the building sat inside it.

A viewport-width number line. From 0 to 767 pixels the app served mobile structure with mobile styling. From 768 to 959.98 pixels it served mobile structure with desktop styling, the dead zone containing iPad Mini at 768, iPad Air at 820 and iPad Pro 11-inch at 834. From 960 pixels up it served desktop with desktop. VIEWPORT WIDTH MOBILE STRUCTURE · MOBILE STYLING DESKTOP · DESKTOP 0 767 960 1200 768 – 959.98px: mobile structure, desktop styling No mobile media query ever matched in this range. iPad Mini 768 · iPad Air 820 · iPad Pro 11″ 834
The hollow marker at 1024 is iPad Pro 12.9″, the only iPad that fell outside the gap, and therefore the only one that looked correct. Which is a fair summary of why nobody had filed it.

Here is what that produced at 820px, iPad Air width. A desktop header with the full icon row, points wallet and profile avatar, and a mobile bottom navigation bar, both on screen. Two navigation systems, neither aware of the other. The feed card underneath runs edge to edge with no containment, and the post’s image well opens into an empty rectangle big enough to push the like and comment actions off the bottom of the screen.

Before · 820px

The feed at 820 pixels before the fix: a desktop header and a mobile bottom navigation bar on screen at the same time, with the feed card running edge to edge.

After · 820px

The same route at 820 pixels after the fix: one mobile header, a section strip with a More overflow, the composer promoted to the top, and the feed contained to a readable column.
Same width, same route, same account. After: one navigation system, a mobile header, an icon-and-label section strip with a “More” overflow, the recognition composer promoted to the top where the primary action belongs, filters as pills, and the feed contained to a readable column instead of sprawling across 820px.

The obvious next move is to change one number so they match. That is not what this turned out to be.

When I went looking for the breakpoint system to correct, there wasn’t one. The TypeScript checks and the CSS queries had been written separately, years apart, by different people, each solving a single screen. No shared definition of what “mobile” meant existed anywhere in the codebase. The two numbers didn’t disagree because someone made a mistake. They disagreed because nothing had ever made them agree.

So I wrote it: a deliberate set of breakpoints, applied consistently across the app, which meant auditing every component that made a size decision and bringing it onto the new system. Roughly 60 components were affected.

The decision underneath it was where to put the boundary. Dropping the threshold to 767 would have handed every iPad the full desktop layout: consistent, and miserable on an 820px screen. Going the other way meant committing to a position.

A portrait iPad is a big phone, not a small desktop.

I set it at 960. Small iPads in portrait get the mobile layout, where the touch targets and single-column reading order suit how people hold them. iPad Pro 12.9″ and anything in landscape gets desktop, which it has the room for.

Density without deleting the celebration

The rewards leaderboard has a podium: top three, with crowns, laurels, ranked colour treatments, and an outline highlighting you if you are on it.

On a 390px phone it had two problems. The podium gave each person a full-bleed block of colour with their rank rendered as a separate laurel graphic below the name, so one person filled most of the viewport. Seeing all three meant scrolling through three screens of a component whose whole job is instant comparison. Above it, the time-range tabs had given up: “This Quarter” and “This Year” overlap mid-word, because the strip had no overflow strategy.

Before · 390px

The leaderboard on a 390 pixel phone before: each podium place fills a full block of the viewport, and the time-range tabs overlap mid-word.

After · 390px

The leaderboard after: all three podium places in one card, rank shown as a rosette on the avatar, department names visible, and the time-range tabs no longer colliding.
All three places now sit in one card, in roughly the room one person used to take. Crowns, laurels, rank colours and the “you” outline all survived.

The quick fix for a podium that doesn’t fit is to drop it on mobile and render three rows with medal chips. It is clean and responsive and it deletes the reason the feature exists. Recognition products run on that small moment of delight; flattening it to a list is a design failure wearing a technical improvement as a disguise.

I compressed instead. The rank moved onto the avatar as a rosette badge rather than sitting below the name as its own graphic. The laurel wrapped the avatar instead of taking a separate row. The vertical space that freed up went to each person’s department, which is the context that makes a leaderboard readable in a company of any size. The section strip got the same treatment: boxy rectangles that overflowed became icon-and-label items with a sticky “More” menu, so the row can’t collide with itself as sections get added.

A surface that exists only on mobile

Redemption on desktop runs off a subheader: a row of options across the top of the store, gating what you can reach based on your wallet, your region, and which programs your company has enabled. That row has nowhere to go on a phone.

The mobile-only Redeem hub: each redemption route as a card with its own illustration, and a gift icon tab active in the bottom navigation.
The Redeem hub exists only on mobile and tablet. Desktop users never see it; a route guard sends them to the gift-card store as before.

Vantage Circle also has a native mobile app, and it doesn’t use a subheader; it uses a redemption landing screen. So the answer wasn’t to invent a mobile pattern from nothing. It was to bring web into line with the app, so someone moving between the two isn’t relearning where redemption lives.

On mobile the hub becomes the front door: each redemption route as a card with its own illustration and a clear next step, carrying the same gating logic the desktop subheader applies, multi-wallet cases included. It changed the bottom navigation too. Profile was the weakest item there: profile is reachable from the header, and redemption is what people open the app to do. I swapped it for the gift-icon Redeem tab, and wired every back arrow in the flow to return to the hub instead of dropping users into browser history.

Of everything here, this is the piece I would want to talk about in an interview. It only becomes available once you stop treating mobile as desktop with less room and start treating it as its own product surface with its own conventions, ones this company had already established elsewhere.

Finding the real scroll owner

I wanted the bottom navigation to hide on scroll down and come back on scroll up, so content gets the screen back while reading. The standard implementation listens for scroll on the window. I wrote it, and nothing happened.

On mobile this app renders inside a Material drawer, and the element that actually scrolls is the drawer’s content container. Not the window. Every window-level scroll listener in the mobile shell was silently dead, and had been for as long as the shell existed. Nothing had needed one before, so nobody had found out.

// Never fires on mobile. The window doesn't scroll; the drawer does.
window.addEventListener('scroll', onScroll);

// The drawer's content element is the real scroll container.
scrollContainer.addEventListener('scroll', onScroll);

No error, no warning. The first version is valid code that runs happily and reports nothing, which is why it survived. I built the fix as a reusable directive that attaches to the true scrolling ancestor, so the next person wanting scroll behaviour doesn’t rediscover this.

Two details worth keeping. The bar hides by translating down its own height plus the bottom inset, the device safe-area, and 24px of slack. The bar carries an upward shadow, and without that extra clearance a 16px shadow blur stays visible below the fold as a grey smudge. And the transition is disabled under prefers-reduced-motion, because a nav bar that animates on every scroll is exactly the kind of motion that makes people with vestibular sensitivity put the phone down.

Markup that looked correct and did nothing

The gift-card store showed one card per row on mobile. One card, in a whole phone viewport, for content that is fundamentally a grid of brand logos. Everything above it made that worse: a breadcrumb, a separate FAQ link, and a full-width search field, all spending screen before a single redeemable item appeared.

The markup said col-xs-12. It looked deliberate. Someone had clearly specified the mobile column behaviour.

The project runs Bootstrap 4, which removed the xs infix. col-xs-* is not a class in Bootstrap 4. It had been doing nothing, silently, for years, and because it read as correct, everyone who opened that file concluded mobile was handled and moved on. Including me, the first two times.

<!-- Bootstrap 3 syntax. In Bootstrap 4 this class simply does not
     exist, so the element falls back to full width. -->
<div class="col-xs-12">

<!-- Bootstrap 4 dropped the xs infix. The bare class is the
     mobile column. -->
<div class="col-6">

Before · 390px

The gift-card store on a phone before: one card per row, preceded by a breadcrumb, an FAQ link and a full-width search field.

After · 390px

The gift-card store after: a two-up grid showing four cards, with a back arrow and title replacing the breadcrumb and search collapsed to an icon.
Four cards visible where there had been one. The reclaimed space went to the header as well: breadcrumb replaced by a back arrow and title, search collapsed to an icon, filtering moved to a floating button.
What the markup saidWhat the browser did
col-xs-12Nothing. No such class exists in Bootstrap 4, so the element fell back to full width.
col-6Half width from the smallest breakpoint up: the two-up grid the page was always supposed to have.
A one-word fix that took a day to find. Dead code that looks alive is more expensive than code that is obviously missing.

Fixing without force

The codebase had the usual !important sediment. It is where you end up when a lot of people patch styles under deadline, and it compounds: every one of them raises the floor for the next person.

I worked under a constraint of no new !important, enforced by a linter rule and a pre-push hook. Every override had to win the cascade honestly: specificity, rule order, or setting the design token the component library actually reads. That constraint taught me more about this codebase than anything else I did.

Angular’s scoped styles already give component rules more specificity than most people assume, so a large share of the existing !important declarations were never necessary. The rule underneath would have won on its own.

/* What you write in a component stylesheet */
.card-title { font-size: 14px; }

/* What Angular actually compiles it to. The scoping attribute
   counts, so this is specificity 0-2-0, not 0-1-0; it already
   outranks the global utility it was fighting. */
.card-title[_ngcontent-abc] { font-size: 14px; }

Angular Material is the opposite case

Material injects its component CSS at runtime, after the stylesheets load, so a direct override loses that race no matter how specific you make it. Raising specificity is the intuitive response, and it doesn’t work. You have to set the token Material itself reads.

/* Loses. Material's own rule is injected later and wins on
   cascade order, however specific this gets. */
.mat-mdc-menu-panel { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }

/* Wins. Setting the token means Material applies the value. */
.mat-mdc-menu-panel {
  --mat-menu-container-elevation-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}

Which is also just the supported way to do it: tokens are the public API, overrides are reaching behind the component and hoping. This is the same mechanism that made Material’s date pickers ignore tenant branding in the design-system project, and that fix had to go through the token API for the same reason.

Material’s typography is a third variant: it beats single-class font utilities on headings by pure arithmetic, .mat-typography h2 scoring 0-0-1-1 against .fs-14’s 0-0-1-0. Every font utility competing on an h1h6 had to be written around that.

None of this is visible. The UI looks the same. The next person to change these styles can do it in one line instead of five.

Knowing what not to fix

Two of my better calls were leaving things alone.

The activity feed had uneven padding and clipped badge images, visible in the captures at the top of this page, where the post’s image well opens into an oversized empty rectangle. I went in expecting a stylesheet bug and found it came from inside a shared internal package rather than this application. Overriding it locally would have taken ten minutes. I documented the root cause for the package owner instead. A local override fixes it for one app while every other consumer keeps shipping the bug, and it breaks the next time the package updates.

A promotional banner was cropping its artwork. Same instinct, different answer: a fix was already in flight from another team, and patching it locally would have handed someone a merge conflict and saved nobody anything.

Both are the same question. Is this mine to fix? Not every bug you can reach is one you should touch.

Also shipped

  • Rewards & perks subheaders. The desktop bar was positioned so that on iPad it detached and floated over the feed while scrolling. Bringing both onto the mobile treatment inside the corrected breakpoints killed the overlap.
  • Feed cards on tablet. The feed’s own max-width pinned cards to the left edge instead of centring them on iPad. Fixed without touching the shared package.
  • Mobile navigation drawer. Rebuilt to mirror the desktop profile menu, minus the admin destinations. Administration is desktop work, and those entries add weight to a menu for a task nobody completes on a phone.
  • Rewards mobile header. Removed the colleague search field, which spent a full row of prime header space on an action people rarely take on mobile.
  • Profile widget. A mobile-specific card with a soft brand-colour gradient and the two primary recognition actions docked side by side, in thumb reach.
  • Experience section. Category strip, tile grids, and a consistent path back to the hub.

How the work was split

I owned the mobile experience end to end: the audit, the breakpoint system, and the design and implementation across the app. Sayani and Anindita repaired mobile issues on the pages they owned, so their surfaces came onto the new breakpoints alongside mine. The rest of the contributors on the branch were developers keeping builds green and clearing issues the test team raised.

Worth being clear about, because the commit history shows ten names on this branch and only some of that work is mine.

What I’d take from it

Given “make it better on mobile”, the tempting move is to open the worst-looking screen and start adjusting. I spent the first stretch measuring instead, which is why the rest went as fast as it did. Everything after the breakpoint work was cheaper because the breakpoint work happened first.

The larger lesson is less flattering to the work. Building this from scratch would have been easier than what I did. Retrofitting a thirteen-year-old platform that was never meant for phones costs more than designing for mobile from the first commit ever would have. No mobile layer existed. The grid classes lie. The scroll listeners point at the wrong element. Every fix here was also an excavation.

So: mobile first, always. Not as a slogan, but as a genuine estimate of what the alternative costs, from someone who has now paid it.

The other thing I would keep is that working in the codebase changed what I could design. The Redeem hub, the hide-on-scroll bar, the podium compression: none of those were specifiable as static screens. They came out of knowing what the code could actually do, which is a different conversation than handing over a mockup and hoping.

Every screenshot on this page is a real capture from a running build, taken at the same viewport, route and account on both sides of each pair; the “before” images come from the actual pre-project commit, not a reconstruction. All captures were taken through an anonymising proxy: every name, avatar and email address in them is generated, and the initials avatars are drawn rather than photographed. The code samples are illustrative, written out to demonstrate public framework behaviour (Bootstrap’s grid classes, Angular’s style scoping, Material’s token API) and contain no source, class names or file structure from the employer’s codebase.