Fenmore Premium
19.24%–28.24% variable APR
- 3x points on travel
- No foreign transaction fee
Rates as of today. Terms apply.
fenmore · design system
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.
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
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
19.24%–28.24% variable APR
Rates as of today. Terms apply.
17.99%–26.99% variable APR
Rates as of today. Terms apply.
layer 1
| Token | Value | Note |
|---|
layer 2
Components never touch a primitive. That indirection is what makes the reskin one edit rather than a find-and-replace.
| Token | Resolves to | Contrast |
|---|
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
“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.
Include all sources you have access to.
| Card | Annual fee | Intro APR | Rewards |
|---|---|---|---|
| Fenmore Premium | $95 | None | 3x travel |
| Fenmore Everyday | $0 | 15 months | 2% flat |
| Fenmore Foundation | $0 | None | 1.5% flat |
the inverse
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.
| Role | On light | On the inverse | What inverts |
|---|---|---|---|
border-default | neutral/200 #E3E2E6 | chrome/700 #4E4D59 | Darker than its background → lighter than it |
interactive | accent/600 #4E7278 | accent/300 #95B8BB | accent/600 is 2.0:1 on chrome — invisible. This is the single strongest argument for having an inverse. |
surface-raised | neutral/0 — lighter | chrome/700 — lighter | Nothing. Raised means nearer, and nearer means lighter on both. |
text-muted | neutral/600 | neutral/350 | The 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
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.
| Step | On paper | On chrome-800 | Verdict |
|---|---|---|---|
brand/500 unchanged | 1.76:1 | 5.68:1 | The identity value. Fill on light, text on chrome, and the selected-chip fill. |
brand/600 | 2.28:1 | 4.39:1 | Fails the bar by a hundredth. Permitted as text on chrome-900 only (5.82:1). |
brand/700 | 3.59:1 | 2.78:1 | The dead zone. Fails as text everywhere in the system. Kept anyway — see below. |
brand/800 | 5.67:1 | 1.76:1 | The 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
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
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.
| State | Treatment | Why |
|---|---|---|
| Rest | Transparent, border-strong hairline | Nothing is filled, so selection has somewhere to go. |
| Hover | surface-raised + accent border | Raised means nearer, which means lighter on both surfaces. One rule, no variants. |
| Selected | brand/500 fill, chrome-900 label, brand/700 edge | 7.53:1. Chartreuse is loud and scarce — only some chips are selected, which is what makes it read as a state. |
| Count | text-secondary, same size | Steps 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
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.
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
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.
| Token | Value | Use |
|---|---|---|
opacity/disabled | 0.4 | Disabled controls. Never applied to text that has to be read — a disabled button still needs a legible label. |
opacity/subtle | 0.6 | Decorative overlays on chrome. |
opacity/scrim | 0.45 | Behind a modal or dropdown. |
scales
| Token | Value | Use |
|---|
workflow
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