fenmore · design system

A fictional card issuer, built from tokens.

Every abstracted screen in this portfolio is composed from the components below and screenshotted at 2x — rather than redrawn in Figma. Consistent by construction, confidentiality-safe by construction, and reskinnable in one edit.

Why this exists

Eighty tokens. Two layers. To reskin, you change nineteen primitive hexes and nothing else — semantics, components and every screen follow.

The confidentiality argument. A blurred real screenshot still carries the real layout. A screen composed from fenmore tokens isn't the client's property in any recognisable sense, because it was never their design — it's a plausible card marketplace built from scratch.

The speed argument. Thirty-odd abstracted screens redrawn by hand is a week. Composed from components, it's an afternoon, and every one is pixel-consistent with the others.

The verification argument. build.py refuses to generate CSS if any text token fails contrast on a surface it's permitted on. A reskin that breaks legibility fails loudly instead of shipping.

reskin

Change the primitives, watch everything follow

Three of the nineteen primitives are actual decisions — a chrome hue, an accent hue, a brand hue. The rest is a neutral ramp. These presets swap only those, live:

every component below responds to the switch above

Fenmore Premium

19.24%–28.24% variable APR

  • 3x points on travel
  • No foreign transaction fee
Good odds

Fenmore Everyday

17.99%–26.99% variable APR

  • 2% back on all purchases
  • No annual fee
Fenmore Premium 19.24%–28.24% variable

layer 1

Primitives — the only layer you edit

TokenValueNote

layer 2

Semantics — what components reference

Components never touch a primitive. That indirection is what makes the reskin one edit rather than a find-and-replace.

TokenResolves toContrast

Contrast is measured against the worst light surface a token is permitted on. Fill-only values are supposed to fail as text — that's their job, and the build script asserts it.

components

The pieces the screens are made of

Buttons

Badges

Newest offer Good approval odds Featured

Category panel — with the count treatment

“Pay less” is a goal, not a product attribute — it collects no-annual-fee, 0% intro APR and balance transfers under the reason someone would want any of them. That naming decision is the cognitive-load hypothesis in two words.

Filter rail and form fields

Narrow by

Include all sources you have access to.

Comparison table

CardAnnual feeIntro APRRewards
Fenmore Premium$95None3x travel
Fenmore Everyday$015 months2% flat
Fenmore Foundation$0None1.5% flat

the inverse

An inverse is a surface. A dark mode is a second system.

A dark mode is a parallel copy of every token, wired to prefers-color-scheme, that doubles the maintenance and doubles the places a contrast failure can hide. That is not what a nav bar needs. The bar is dark because it is chrome — not because of anyone’s OS setting — and it sits on the same screen as the light content at the same time.

So the inverse is thirteen tokens and one scope. Roles invert inside it: the hairline goes lighter than its background instead of darker, and the interactive value moves up the accent ramp instead of down. Components never reference the inverse names — the scope re-points the standard ones, so .fen-btn--primary is one class, not two.

This was also an aesthetic bug, not just an architectural one. With no inverse, text-secondary resolved to a dark neutral on a dark panel. The only legible colour left to reach for was chartreuse — which is why five column headers ended up in the accent. The lime rainbow was a symptom of the missing surface.

RoleOn lightOn the inverseWhat inverts
border-defaultneutral/200 #E3E2E6chrome/700 #4E4D59Darker than its background → lighter than it
interactiveaccent/600 #4E7278accent/300 #95B8BBaccent/600 is 2.0:1 on chrome — invisible. This is the single strongest argument for having an inverse.
surface-raisedneutral/0 — lighterchrome/700 — lighterNothing. Raised means nearer, and nearer means lighter on both.
text-mutedneutral/600neutral/350The third tier goes lighter, and only just clears on the bar (4.74:1).

The bar and the panel have different contrast budgets. chrome-800 tops out at 10.7:1 for white and affords two comfortable text tiers; chrome-900 reaches 14.2:1 and affords three. The gate now checks them separately, which is why sustained reading belongs on the panel and not on the bar.

brand ramp

Saturation has to rise as chartreuse darkens, or it turns olive

A 58° yellow-green is the single most fragile hue to build a ramp from. Darken it at constant saturation and you land on army olive; lighten it and it goes grey-green and sickly. So saturation climbs from 36% to 68% across the ramp while hue drifts 55°→64° — nine degrees, which is inside the range that still reads as one family.

100wash
200fill
300fill
400fill
500identity
600fill
700dead zone
800text on light
StepOn paperOn chrome-800Verdict
brand/500 unchanged1.76:15.68:1The identity value. Fill on light, text on chrome, and the selected-chip fill.
brand/6002.28:14.39:1Fails the bar by a hundredth. Permitted as text on chrome-900 only (5.82:1).
brand/7003.59:12.78:1The dead zone. Fails as text everywhere in the system. Kept anyway — see below.
brand/8005.67:11.76:1The value the ramp didn’t have: chartreuse as text on light. Chartreuse can now label a white card, not only a dark bar.

The dead zone earns its keep as a border. A chartreuse pill on white is 1.8:1 and has no visible edge at all. brand/700 is useless for text and clears 3.6:1 as a non-text border — which is exactly the selected chip’s outline. A value that fails one job can be the only right answer to another.

buttons

Three tiers, each separated on two axes

primary is fill, no border. secondary is border, no fill. ghost is neither. Never fill-strength alone — a trio told apart only by how saturated its fill is collapses in greyscale, at a glance, and for anyone with low colour vision. The first pass failed this: two teal fills of similar value reading as two equal primaries, so the page had no primary at all.

on light

on the inverse — same three classes, no variants

One primary per view. And it is teal, not chartreuse — the loudest colour in a palette should not be the default call to action, because then every screen is shouting and the accent has nothing left to mark. Chartreuse marks selection instead.

Ghost keeps the box. Same padding, same min-height. A ghost that collapses to bare text can’t sit in a row with the other two — which is what --tertiary did, and why it’s now a deprecated alias.

chips

Hairline at rest. Filled when selected.

The old chip was filled at rest, which left selection nowhere to go — every chip already looked switched on, so a filter row read as a stack of buttons. A fill reads as state; a rule reads as an affordance. Same principle as the annotations in the case studies.

StateTreatmentWhy
RestTransparent, border-strong hairlineNothing is filled, so selection has somewhere to go.
Hoversurface-raised + accent borderRaised means nearer, which means lighter on both surfaces. One rule, no variants.
Selectedbrand/500 fill, chrome-900 label, brand/700 edge7.53:1. Chartreuse is loud and scarce — only some chips are selected, which is what makes it read as a state.
Counttext-secondary, same sizeSteps down by colour, not by size. Two sizes inside one pill is one too many. Inside a selected chip it steps down by weight, because opacity on text is how you get a 3:1 label you never measured.

The icon circles are gone. They were the highest-contrast element on the screen and carried the least information in it. Icons now ride at 16px in currentColor, or not at all.

nine styles

Every text style has a job it is the only one able to do

A style with no job gets applied arbitrarily; a missing one gets faked with a one-off override. Nine is where those two failures stop. Tracking is stored as a percentage so it scales with size, and label carries its uppercase as a token — components no longer set text-transform themselves.

text/display
32 / 700 / -2%
Pick a card
text/heading
24 / 700 / -1.5%
Fenmore Premium
text/subheading
20 / 500 / -1.5%
Balance transfer cards
text/body
16 / 400 / 0%
Earn unlimited rewards on every purchase, with no annual fee.
text/body-strong
16 / 500 / 0%
No annual fee
text/body-sm
14 / 400 / 0%
Intro APR for 15 months on purchases
text/action
14 / 500 / +1%
Compare cards
text/label
12 / 500 / +8% / upper
Rewards rate
text/legal
11 / 400 / 0%
Variable APR 19.24%–28.24% based on creditworthiness. Terms apply.

body-sm and action are the same size on purpose. One is dense reading, the other is something you click, and separating them means every button in the system can be restyled without touching a single table cell.

borders & opacity

A border is one decision, not three

Colour, width and style travel together as a composite, so there is no way to set a 2px border in the hairline colour by accident.

border/default1px hairline — cards, table rows, dividers
border/strongInputs and secondary buttons — needs to read as an edge you can type inside
border/focus2px, brand colour. Never removed without a replacement.
border/selectedSame value as focus, separate token — they diverge in most reskins
TokenValueUse
opacity/disabled0.4Disabled controls. Never applied to text that has to be read — a disabled button still needs a legible label.
opacity/subtle0.6Decorative overlays on chrome.
opacity/scrim0.45Behind a modal or dropdown.

scales

Type, space, radius

TokenValueUse

workflow

Composing a screen

Wrap markup in .fenmore, assemble from the components, screenshot at 2x. Nothing else needed.

<!-- a category page fragment -->
<div class="fenmore">
  <div class="fen-nav">…</div>
  <div class="fen-chips">…</div>
  <div class="fen-grid">
    <article class="fen-card">…</article>
  </div>
</div>

Regenerate after any token edit:

python3 build.py
# 80 tokens → fenmore.css
# ✓ contrast gate passed