Skip to content
← Work

Case study

MOKO Meta Dash Switcher

A Chrome extension that turns account switching in Meta Business Suite from a series of clicks into a single keystroke.

Role
Design, architecture, implementation, testing — end to end
Type
Chrome extension (Manifest V3)
Stack
React 19, TypeScript, Vite, Tailwind CSS 4, Fuse.js, Vitest
Scale
~4,400 lines of production code, 22 unit tests, 11 UI languages
Status
v1.0 packaged and prepared for release

The problem

Marketing agencies and social media managers run a dozen — often several dozen — client accounts in Meta Business Suite at the same time. Meta’s interface assumes you work on one account at a time: moving from one client’s inbox to another client’s posts means opening the account switcher, scrolling the list, picking an account and waiting for the view to reload.

At twenty accounts and several dozen switches a day, that stops being a minor annoyance and becomes a tax on every working hour — and worse, a cognitive one. You lose the thread before the page finishes loading.

The solution

A command palette built directly into Business Suite. Cmd+K on Mac, Ctrl+K on Windows, a few letters of the client’s name, Enter — you’re there.

The pattern is familiar to anyone who uses Spotlight, Raycast or the VS Code command palette. The entire value of the project lies in bringing that reflex to a tool that never had it.

What it does

  • Fuzzy search across account name and ID (Fuse.js, threshold 0.3) — typos and abbreviations don’t get in the way
  • Automatic account detection — the extension recognises the account you’re on and saves a shortcut to it
  • Recent section — the three most recently used accounts always at the top
  • List management — inline rename, drag & drop reordering, delete with confirmation, JSON export and import
  • Full keyboard controlEnter (current tab), Cmd+Enter (new tab), Shift+Enter (save current account), ↑↓, ESC
  • 11 interface languages — English, Polish, German, French, Spanish, Italian, Portuguese, Czech, Hungarian, Ukrainian, Greek

Technical decisions

Isolation: Shadow DOM in closed mode

Injecting your own UI into someone else’s application is usually a cascade war — the host’s styles break your extension, your extension’s styles break the host. This one mounts inside a Shadow DOM in closed mode, with CSS delivered through adoptedStyleSheets rather than a <style> tag.

That buys two things at once: an appearance that is hermetic regardless of what Meta changes in its CSS, and a security boundary — page scripts have no handle on the extension’s tree, so they can’t read the client list out of it.

A scraper that doesn’t guess

The hardest part turned out to be resolving the account name. The identifiers (asset_id, business_id) sit in the URL and are trivial to extract — the client’s name is either everywhere or nowhere, depending on which corner of Business Suite you’re in and whether the page has finished hydrating.

The answer is a cascade of roughly ten detection strategies — URL path, localStorage, sessionStorage, 65+ CSS selectors, document title, navigation, breadcrumbs, DOM scan — tried in order until one hits, with three asynchronous retries if the page hasn’t finished rendering.

This is defensive by design, deliberately so: identifiers are validated (numeric only), and target URLs are constructed with URL and searchParams.set(), never by string concatenation. The host DOM is treated as untrusted input, because that is exactly what it is.

Permissions: as many as required, not one more

PermissionWhy
storageAccount list in chrome.storage.local
activeTabReading the current tab’s URL
scriptingInjecting the content script on demand
host: business.facebook.comThe only domain the extension runs on

The extension does not inject into personal Facebook, Instagram or any other Meta property. It has no cookie access, makes no network requests, and collects no telemetry. All data stays in the user’s browser and can be exported or wiped in one click.

That list isn’t just hygiene. For a tool that can see an agency’s entire client roster, trust is a product feature rather than an afterthought.

Quality

Unit tests (Vitest + jsdom) cover the logic that has to be certain: domain and page-type detection, storage write/read round-trips, deduplication and the cap on the “recently used” list, link parsing in the scraper, and keyboard shortcut handling. On top of that, an Error Boundary around the UI so an exception in the extension can never take down the host page, and a clean TypeScript build with zero errors.


Process

The project went through three complete iterations, each preserved separately:

  1. Prototype — command palette on mock data. The goal was to find out whether the interaction felt good at all, before writing anything hard.
  2. Real-data version — rebrand to MOKO, mocks replaced with the scraper. This is where it became clear that account-name detection was the actual problem of the project.
  3. Production version — internationalisation, tests, options page, FAQ and privacy policy, a .zip bundle ready for the Chrome Web Store.

The order wasn’t accidental. The first iteration answered “is this worth doing”, the second “can this be done”, the third “can a stranger use this safely”. Doing it the other way round would have cost considerably more time.

What the project taught me

Injecting UI into someone else’s product is a contract the other party never signed. Meta can change its DOM structure at any moment and owes no warning. Every architectural decision here — Shadow DOM, the strategy cascade, input validation, retries — follows from one assumption: every single anchor point into someone else’s application will eventually break.

A keyboard shortcut isn’t a feature, it’s a removed barrier. The value isn’t in doing something new, it’s in no longer requiring something. That’s why the priority was making the palette open instantly and making the whole path completable without touching the mouse — not giving it more options.


Source code and technical detail available on request.

Need a similar solution?

Get in touch ↗