DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four regions, three flags, and a no-emoji rule that cannot see a single one of them

Munchable scans a packaged food and tells you whether it fits the gut conditions you manage. The conditions are at munchable.app/conditions, the terms that cover it are at munchable.app/terms, and the app is React Native through Expo.

Onboarding is eight steps, and the third one asks "Where are you?". Here is the list of answers, in full, with the flag values written as escapes because the actual characters are the subject of the second half of this post:

export const REGIONS: readonly RegionMeta[] = [
  { id: 'us',    label: 'United States',  flag: '\u{1F1FA}\u{1F1F8}', note: 'US wellness app framing.' },
  { id: 'uk',    label: 'United Kingdom', flag: '\u{1F1EC}\u{1F1E7}', note: 'UK general wellness framing.' },
  { id: 'eu',    label: 'European Union', flag: '\u{1F1EA}\u{1F1FA}', note: 'EU general wellness framing.' },
  { id: 'other', label: 'Somewhere else', flag: '\u{1F30D}',           note: 'General wellness framing.' },
];
Enter fullscreen mode Exit fullscreen mode

Four options for a planet with 195 countries in it. One of them is a continent, one is a political union, and one is "somewhere else".

What picking one actually does

Not very much, and that is the point. Grep the app for consumers of the stored value and you find three screens reading it, all of them handing it to one function:

export function extraDisclaimers(
  region: RegionId,
): { key: string; text: string }[] {
  const medical = region === 'eu' || region === 'uk' ? NOT_MEDICAL_EU : NOT_MEDICAL_ADVICE;
  return [
    { key: 'medical', text: medical },
    { key: 'allergy', text: ALLERGY_FIREWALL },
  ];
}
Enter fullscreen mode Exit fullscreen mode

Two wordings of the same statement. One says Munchable is a wellness app and not a medical device; the other says it is a general wellness app and not a medical device under EU and UK rules. The mandatory per-scan line is shown separately and always, and these two sit behind a "More" expander so a result screen is an answer rather than a wall of legal text.

The region never reaches the rules engine. I checked: the engine's source does not contain the word. Nothing about which ingredients are read, or how, changes with it. The only thing a reader's choice changes is which phrasing of a disclaimer they see.

Which makes the onboarding copy wrong, and I only noticed while writing this:

We tailor wording and label rules to your region, so you only see what applies to you.

Wording, yes. Label rules, no. The sentence promises a second thing the code does not do, and the fix is to delete three words rather than to build the feature, because the feature is not one I want: a product that quietly reads a label differently depending on a dropdown is harder to trust, not easier.

Why a bucket instead of a country list

The temptation with a regulatory setting is to ship a country picker, because country pickers look thorough. The reason this one has four entries is that a disclosure has to be true where the reader is, and "true" is a property of a legal regime rather than of a flag. There are a small number of regimes we have reviewed wording for. Offering 195 options would imply we had reviewed 195.

"Somewhere else" exists for the same reason. A user in a country we have no specific wording for gets the general wording, which is the honest answer. The alternative, picking the nearest regime and hoping, is a claim about their law that nobody at this end has checked.

The default is us, so the first row is already selected when the screen opens and the step can be passed with one tap. An unanswered jurisdiction question has to resolve to something, and it may as well be the one the wording was written for first.

The second thing called region

The app has another concept with the same name, in a different file, doing an unrelated job: which currency to write prices in. That one is a real country code, and it is not asked for. It is read out of the device's locale:

export function regionFromLocale(locale: string | null | undefined): string | null {
  if (!locale) return null;
  const [, ...subtags] = locale.trim().split(/[-_]/);
  for (const subtag of subtags) {
    // A single-character subtag opens an extension (`-u-ca-gregory`), and
    // everything after it is extension data: `ca` there is a calendar, not
    // Canada. Stop rather than read one as a country.
    if (subtag.length <= 1) break;
    if (/^[A-Za-z]{2}$/.test(subtag)) return subtag.toUpperCase();
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

That function has its own war story about the ways a locale tag lies to you. What is relevant here is why the two never got merged, which is written down beside it:

The two signals disagree for someone abroad on holiday, and the device wins. That is the better answer: a British phone in Spain belongs to someone who still pays in pounds.

Money wants a fact about the handset: synchronous, offline, no round trip, no spinner in front of a price. Disclosure wants a statement from the person: changeable in settings, not re-derived behind their back, and not altered by a fortnight in Spain. Merging them would mean either a holiday changes the legal wording you are shown, or a settings change rewrites the price on your paywall. Two things that happen to answer "where are you" with the same word, and they are better off apart.

The flags, and the rule that cannot see them

House style on this project bans emoji and em dashes from everything: product copy, docs, commit messages. There is a test that enforces it over the recipe library:

/** House style (docs, and the app's copy rules): no em dashes, no emoji. */
forEachRecipe('copy follows house style', (r) => {
  const copy = [r.title, r.summary, ...r.steps];
  for (const text of copy) {
    assert.ok(!text.includes('\u2014'), `"${text}" contains an em dash`);
    assert.ok(!/\p{Extended_Pictographic}/u.test(text), `"${text}" contains an emoji`);
  }
});
Enter fullscreen mode Exit fullscreen mode

I ran a pictograph scan over every tracked source file in the repo. The results: one file with emoji in it, the region list, carrying three flags and a globe. Everything else that matched was a copyright sign in two footers, a play triangle in a render script, and a double-headed arrow in some docs and comments.

Then I checked whether the rule above would have caught them:

> /\p{Extended_Pictographic}/u.test('\u{1F1FA}\u{1F1F8}')   // the US flag
false
> /\p{Extended_Pictographic}/u.test('\u{1F30D}')            // the globe
true
Enter fullscreen mode Exit fullscreen mode

A flag emoji is two Regional Indicator code points, and Regional Indicators are not Extended_Pictographic. So three of the four pictographs in this repository are invisible to the pattern that exists to find pictographs. The globe is not, and would be caught the moment the test's scope reached that file, which it currently does not: it checks recipe copy only.

The exemption is deliberate at review time. A flag beside "United Kingdom" is a tiny piece of national artwork that the OS already ships, and the alternative is four SVGs, four licences and a dark-mode decision. It just is not deliberate in code, which is the difference between a policy and a habit.

If I were enforcing it properly, the pattern would be \p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}], the scope would be every string the app shows, and the exemption would be one allowlisted file path rather than an accident of Unicode property tables.

Why the flag is a string and not an image

One genuinely nice consequence of using emoji here: the flag is rendered as text, in a heading variant, inside the same row component everything else uses.

<ChoiceRow
  title={r.label}
  helper={r.note}
  leading={<Text variant="h2">{r.flag}</Text>}
  selected={region === r.id}
  onPress={() => setRegion(r.id)}
  role="radio"
/>
Enter fullscreen mode Exit fullscreen mode

No asset, no @2x, no tint, and because it is text it scales with the phone's text size along with everything else. That scaling goes up to 3.571 times on iOS, and an image would have needed its own answer to that. The row also announces properly, because ChoiceRow carries role="radio", a checked state and the note as its hint, which came out of an accessibility audit of the same app.

The cost is real though. Windows does not render flag emoji as flags, it renders the letter pair, and this app has a web build. So in a browser on a Windows machine the picker reads "US United States" and "GB United Kingdom" rather than showing a flag. That degrades acceptably because the flag was never carrying the meaning, the label is. If it had been the only label, the picker would be broken on an entire desktop platform and the bug would be in a glyph table rather than in any code you could grep for.

What to take from a four-item list

If your app has a regulatory setting, the number of options should be the number of wordings you have actually had reviewed, and the honest overflow entry is worth more than a long dropdown. If it has two things called region, check whether they are the same question: ours are not, one is a preference and one is a property of the device, and the comment explaining why is doing more work than either function.

And if your house style bans something, check that the pattern enforcing it matches the thing. Mine has been letting flags through since the day it was written.

You can read the disclosure wording the picker chooses between, without installing anything: the general version is on every page of the site, and the full version is in the terms. The conditions the app reasons about are at munchable.app/conditions, and the generated answer pages are at munchable.app/answers.

Top comments (0)