A while back I inherited a design system that had been ported from web to a mini program. The primary color made it. Almost nothing else did.

The tokens were all there. primaryHover, primaryActive, border, surface, textSub. Someone had copied them into the mini program stylesheet in good faith. Then, one at a time, they became dead weight. border had no consumer, because the mini program draws dividers instead. primaryHover sat there doing nothing, because a finger cannot hover.

The usual explanation is "the port was sloppy." I don't think that's it. The three platforms genuinely have different shapes, and almost nobody writes those shapes down. So I did.

I maintain a palette library with 100 palettes across the three platforms (31 app, 34 web, 35 mini program). Every palette has exactly ten color roles. I compared the role sets, the state transitions and the contrast behavior. All figures below are computed from those 100 palettes, so they describe one particular library and not the industry.

Here's what came out.

Ten roles each, and one shared field name

Put the three lists side by side and the intersection is one field name: bg. That's it. Everything else is named differently, or exists on one platform and not the others.

Purpose App Web Mini program
Primary primary primary theme
Darker / interaction primaryDark primaryHover + primaryActive btn
Lighter version primaryLight none tag
Secondary secondary none none
Accent accent accent + link price
Text levels text (one) textMain + textSub textMain + textSub
Divider none border divider
Container none surface card
Status success / warning / danger none none
Disabled none none btnDisabled

Ten roles on each platform. Same count, different sets. Which explains the dead tokens: the mini program had no place to put success, warning or danger, and no consumer for border. Meanwhile it was missing three roles it actually needed, price, tag and divider, because nobody had added them.

So "one brand color across three platforms" is true of exactly one role. The primary can stay. The other nine have to be rewritten per platform, and someone has to decide what they become.

Web darkens along one line, the app spreads to both sides

Web carries two interaction states. The app carries none, and the reason is the input device rather than the screen. A mouse pointer can hover. A finger has no middle state between pressing and lifting.

Measured across the web palettes here, primaryHover sits 13.9 lightness points below the primary (range 5.3 to 14.2), and primaryActive sits 26.1 points below. The second is about 1.9x the first.

The app does something structurally different. It gives the primary two flanks:

  • primaryDark: 14.0 points darker than the primary
  • primaryLight: 32.0 points lighter

The rise on the light side is 2.3x the drop on the dark side. That asymmetry is not arbitrary. The dark variant only ever marks a press. The light variant gets used as a tag background and as the fill of a selected area, which are large surfaces, and a fill that isn't light enough leaves no contrast headroom for dark text sitting on it. The dark side can move a little. The light side cannot.

Working numbers if you're setting these by hand: darken hover by 10 to 14 points, take active to roughly twice that, and push the light variant about twice as far as the dark one in the opposite direction. Narrower than that and the press is invisible. Wider and one tap looks like a different brand.

The mini program hides its accent behind a business word

Web and app both call their accent accent, and it is decorative: badges, highlights, anything that should be noticed without carrying information. The mini program has no accent. It has price.

That rename is the most useful thing in the whole comparison, because it exposes the rule underneath. In a mini program the accent serves a transaction, and price is its only job. Measured here, price sits at a median 4.50:1 against the background, with a low of 3.65:1. Compare that to the decorative accent on the other platforms, which in many palettes sits far below the body text threshold and is fine there.

The generalizable version: whether a color needs to clear a contrast threshold depends on whether it carries information or atmosphere. A decorative accent may sit low. An accent carrying a price, a count or a status may not. Same hue position, two uses, two standards, and the field name is doing the work of telling you which one you're looking at.

Each platform drops what it doesn't need, and the drops are the tell

The omissions are not random. They line up with how each platform organizes a screen.

Web has surface and border, and no status colors. Long pages divide with hairlines and faint fills. A form can run two thousand pixels tall, and a rule tells you something changed.

The mini program has card, divider and tag, and no status colors either. The page is a card stack, one card per unit of information, and the gaps between cards do most of the dividing.

The app has success, warning and danger, and neither surface nor border. Actions on a phone have consequences. A delete or a submit cannot be taken back, so state has to be explicit.

Which settles the first question when porting: don't move colors first. Look at what establishes hierarchy on the target platform. Carrying web colors into a mini program without its spacing gives you cards that collapse into a smear. Carrying app colors to the web leaves three status roles with nowhere to live.

The exception worth knowing about

The primaryLight figure above (32 points lighter) is a median. One palette in the library goes the other way: a primary of #4c8df6 at lightness 63, and a primaryLight of #1e3a63 at lightness 25, darker by 38 points.

That palette is a dark theme. Some dark implementations read primaryLight as "the tinted container for the primary" and return a darkened surface instead of a lightened one. The name didn't change. The value did.

The failure is concrete: if you treat primaryLight as a light fill because of its name, that palette gives you dark navy behind dark text, and the contrast collapses.

So the rule is one line: measure before you trust the name. Compare the lightness of primaryLight against the primary. Lighter, and it's a fill. Darker, and in this palette it's a deep container that wants light text on it. Same check applies to any palette you import and any design system you inherit.

If you only ship one platform, you need four roles

Most projects ship one platform, or ship one and expand later. You do not need thirty roles for that. Each platform has a minimum set, and four roles carry a page:

Platform Minimum set What breaks first without it
Web primary + bg + textMain + border No border and tables and forms lose their structure, leaving spacing to do all the work
Mini program theme + bg + textMain + card No card and the card stack flattens into one long column of text
App primary + bg + text + primaryDark No primaryDark and pressing a button produces no feedback at all

Four roles get a usable interface out the door, with three gaps: no secondary text, no accent, and no disabled state. None of that blocks a launch. All of it shows up afterward, one "why does this look off" at a time.

The order to fill the gaps is not a matter of taste. Secondary text first, then disabled states, then accent. The first affects legibility, the last affects looks, and the middle one decides whether people think a button can be pressed.

The short list

If you're doing the port this week:

  1. Leave the primary alone. Recognition depends on it and it has to match across platforms.
  2. Web: add primaryHover (10 to 14 points darker), primaryActive (about double), border, surface, textSub. No status colors.
  3. Mini program: replace the accent with price and hold it to the text standard. Add card, divider, tag, btnDisabled.
  4. App: add primaryDark (14 points darker), primaryLight (32 points lighter, and verify it is actually lighter), secondary, and the three status colors. No surface, no border.
  5. Dark themes: check the direction of every variant. Names lie at the edges.

Where the numbers come from

Every ratio in this post is computed with the WCAG 2.1 relative luminance formula, not estimated by eye, and every figure is regenerated at build time from the palette data rather than typed into the page. The full write-up, the datasets these figures come from, and two more pieces on secondary text contrast and on why card and border colors are nearly invisible, live at paletteget.com/guides. The thresholds and the method are documented at paletteget.com/method.

If you want to check one of your own palettes, the contrast numbers for all three platforms are listed on each palette page, so you don't have to work them out by hand.

One caveat worth stating plainly: this is 100 palettes from one library, curated by one person. It is a decent sample for spotting structural patterns in how the three platforms differ, and it is not a survey of what the industry does. Treat the ratios as evidence that the difference exists, not as targets to hit.