DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our healthy shopping row is never green, and its off switch is an empty array

Munchable's result screen, after you scan something, is a stack of rows. Some are about the conditions you told it you have. Some are about allergies. One is about a thing we call healthy shopping, which is the reader's own list of what they would rather not buy: preservatives, artificial colours, sweeteners, that shape of preference.

All three look the same. A glyph, a title, one word on the right, and the detail one tap away. That sameness is deliberate, and so is every place the three diverge. This post is about the divergences, because every one of them is a copy decision with a reason, and none of them are visible in a screenshot.

The rows are alike because they are alike from where the reader sits

The component comment states the goal before the code does:

/**
 * What healthy shopping found, in the same shape as an allergy row and a
 * condition row: glyph, what it is, the outcome word on the right, the detail
 * one tap away. Three checks that look alike because they ARE alike from where
 * the reader sits, which means there is nothing new to learn on this screen.
 */
Enter fullscreen mode Exit fullscreen mode

Three different checks, three different internal shapes, one visual grammar. The healthy row even imports two layout constants straight off the allergy row (LEADING_SLOT and BODY_INDENT) so the glyph column and the indented body line up to the pixel between row types. If a reader has to re-learn how to read a row three times on one screen, the screen is the problem.

The thing it does not copy from the allergy row

The allergy row carries a standing caution underneath it. The healthy row does not, and that is the one intentional asymmetry:

/**
 * The one thing it does not share with the allergy row is the standing caution
 * underneath. That sentence is there because an allergy row that reads "not
 * mentioned" could otherwise be taken for "free from"; this row is answering a
 * question the reader wrote themselves, so it answers it and stops.
 */
Enter fullscreen mode Exit fullscreen mode

An allergy is a safety claim. "Not mentioned" and "free from" are different statements about the world, and the gap between them is where somebody gets hurt, so the caution is permanent and no amount of UI tidiness gets to remove it.

A preference is not a safety claim. The reader said they would rather not buy artificial colours; the pack has one; naming it is the whole job. A caveat under that line would be noise, and worse, it would train the reader to skim caveats, which is exactly the habit you do not want them to have by the time they reach the allergy row.

Same component shape, two different kinds of promise, and the copy is where the difference lives.

Never green

This is the function I would most like other people to steal:

/**
 * Caution amber for a finding, muted grey for none. Deliberately not green
 * when nothing was found: this layer never clears a product, and a green tick
 * beside "none found" would be a claim about the whole pack rather than about
 * the handful of things the reader asked after.
 */
export function healthTone(colors: Palette, check: HealthCheck): string {
  return check.hits.length === 0 ? colors.textMuted : colors.caution;
}
Enter fullscreen mode Exit fullscreen mode

Grey, not green. The product has a green in its palette and this row is never allowed to use it.

The reasoning is narrow and worth being precise about. The filter looked for a specific handful of things the reader selected. Finding none of them says nothing about the rest of the pack, and a green tick is read by everybody as "this is fine", which is a verdict about the product rather than an answer to the question asked. The row is not entitled to that sentence, so it is not given the colour that implies it.

The words, which are the actual feature

The row's text comes out of five tiny pure functions. Here are two of them, in full:

/** The row title: the finding count, or the quiet state. */
export function healthTitle(check: HealthCheck): string {
  const n = check.hits.length;
  if (n === 0) return 'Nothing you asked about';
  return n === 1 ? '1 thing you asked about' : `${n} things you asked about`;
}

/** The word on the right of the row. Never a verdict word. */
export function healthLabel(check: HealthCheck): string {
  return check.hits.length === 0 ? 'None found' : 'Found';
}
Enter fullscreen mode Exit fullscreen mode

"Nothing you asked about", not "Nothing to worry about". The first is true. The second is a claim the filter cannot support, and it is the phrasing that would have been written if nobody had thought about it for a minute, because it reads better.

"Found" and "None found", not "Pass" and "Fail", not "Good" and "Caution". Those words belong to the verdict vocabulary elsewhere in the app, and lending them to a preference check would make a preference look like a judgement.

The quiet-state body line is written the same way round: it says what was looked for, never that the product is clean.

Colour is not allowed to be the only channel

/** A distinct glyph per state, so it is never carried by colour alone. */
export function healthIcon(check: HealthCheck): string {
  return check.hits.length === 0 ? 'minus-circle' : 'alert-triangle';
}
Enter fullscreen mode Exit fullscreen mode

Grey plus a minus circle, or amber plus a warning triangle. Same information, two channels, so the row survives greyscale, colour blindness and a sunlit supermarket aisle. The row also sets accessibilityRole="button", accessibilityState={{ expanded }} and an accessibility label that reads the row title in context, so the screen reader gets the state too.

The amber is the one choice here I remain slightly uneasy about. It is the same amber the app's caution verdict uses, so a reader who has learned that amber means "be careful about this product" will import that meaning into a row that is not making that claim. The counter-argument, which won, is that a finding should be visually distinguishable at a glance and inventing a third accent colour for this one row is worse. I still think about it.

One field that exists so the row is not misleading by omission

The result the engine hands over has a hits array and one other list:

/**
 * Additives in the product that no preference covers: phosphates, modified
 * starches, anti-caking agents. Plain names, label order, context only: they
 * never become a hit and never move a verdict. They are here so a curious
 * reader can see the whole additive picture in one place instead of being
 * told about three things and left wondering what else was in there.
 */
alsoContains: string[];
Enter fullscreen mode Exit fullscreen mode

It renders as one closing line of smaller, muted text: "Also in here: ...". Context, not findings. A reader who selected three preferences and got two hits would otherwise have no way to tell whether the pack had three additives or thirty, and the honest answer costs one line.

"Never moves a verdict" is load bearing. The moment a context list can change an outcome, it stops being context and becomes an unreviewed rule set.

The off switch is an empty array

The settings screen for this feature has one switch and one list, and the switch is derived rather than stored:

const on = healthy.length > 0;
Enter fullscreen mode Exit fullscreen mode

There is no separate healthyEnabled boolean anywhere. The off state is the empty list. The screen comment explains why:

/**
 * One switch decides whether the filter runs at all, and the list underneath
 * only exists once it is on. That ordering is the point: an empty list is the
 * off state, so the screen can never show a switch that says "on" above a
 * filter that is checking nothing.
 */
Enter fullscreen mode Exit fullscreen mode

Two pieces of state that can disagree will eventually disagree, and the disagreement here has a specific bad shape: a settings screen confidently reading "on" while every scan silently checks nothing. Deriving the switch makes that state unrepresentable rather than merely unlikely.

Switching it on writes a default selection instead of an empty one, so the feature does something useful before the reader has read a single row of the picker. A feature whose first-run state is "configured but inert" is a feature people conclude is broken.

The compile-time half

The preference ids live in the engine. The cards that display them live in the app, as a Record rather than an array:

/**
 * `Record` over `HealthPreferenceId` is what guarantees every engine preference
 * has a card: leave one out and this file does not compile, which is a better
 * place to find out than a crash on the settings screen.
 */
const META: Record<HealthPreferenceId, Omit<HealthPreferenceMeta, 'id'>> = { ... };
Enter fullscreen mode Exit fullscreen mode

Cheap trick, large payoff. Adding a preference to the engine and forgetting the presentation layer is a build failure, not a bug report. The same pattern is worth reaching for any time one package enumerates something another package has to render.

And the picker's footer tells the truth about its own boundary rather than pretending to be exhaustive: "These are the groups Munchable can read off an ingredient list. If a pack names something you avoid that is not here, tell us and we will add it." A closed list that admits it is closed is more trustworthy than one that implies it covers everything.

Try the part that is public

The filter itself lives in the phone app, where the label is in front of you. The reasoning style behind it is on the website, if you want to check whether we hold the same line in public: the conditions index and a page like coeliac disease, where the limits of what a reader should conclude are stated rather than implied, the hidden gluten reference list, which ends with a section about where the list stops, and the recipes, which show only what fits and never grade anything.

The through-line, if there is one: the hard part of a feature like this is not the detection. It is deciding what the app is entitled to say once it has detected something, and then refusing to say one word more.

Top comments (0)