When a 5♠ looked like a 7: visual hierarchy in Blupoli Cards — Design · Blupoli

The entire problem fit inside one card

The most useful signal in this iteration was not a JavaScript exception, a production outage or a broken solitaire rule. It was smaller than that. On a five of spades, the suit symbols sitting below the corner ranks had grown close enough in size and contrast to the five central pips that the card could read as seven similar spades. The DOM still contained exactly five pips. The domain model still knew the rank was five. Nothing was logically wrong, yet the visual language had become ambiguous.

That matters because a playing card is an unusually compressed interface. In a glance, it needs to communicate rank, suit, orientation and position, and it often has to do so while most of the card is covered by another card. The central pips on a number card are not decorative flourishes: they encode value through a familiar arrangement. The small suit mark inside the index serves a different purpose. It qualifies the rank when only the corner remains visible. If both channels receive the same emphasis, the design starts mixing two semantic layers.

Issue #336 turned the observation into a concrete contract. The rank should lead the index. The small suit should be clearly secondary. The central pips should retain stronger visual presence. The same issue also asked for a set of classic poker-style backs in the appearance selector. What makes the story useful is that this ambiguity did not come from a neglected prototype. It appeared after several deliberate improvements to the shared deck.

To understand why, it helps to look at the short sequence of changes that preceded the final fix and at how each one made sense on its own.

The first right move: make A–10 behave like real card faces

Pull request #314 replaced a much simpler card face with a shared renderer that gave number cards explicit pip layouts from two through ten, a dedicated ace treatment, and original symmetrical vector compositions for Jack, Queen and King. The work lived in apps/cards/src/ui/renderers.js and apps/cards/styles.css, rather than inside individual games. That distinction was fundamental. Klondike, Spider, FreeCell, Pyramid, TriPeaks and every solitaire added later should not invent their own visual grammar for a five of spades.

The renderer began describing the geometry of each number directly. A five uses four corner pips plus one in the centre. A ten uses ten declared positions. Court cards are built from project-owned SVG shapes. The ace gets its own centre mark. Browser tests were expanded to verify that a rendered ten genuinely contained ten pip nodes and that the ace treatment remained visible on a mobile viewport.

This was a real improvement in recognition. It also introduced a responsibility the old simplified face did not have. Once the middle of a card carries a literal visual count, every other symbol using the same suit needs to be differentiated clearly. In the earlier design, a large corner suit could not easily be mistaken for part of a pip pattern because there was no complete pip pattern to join. After #314, the centre had gained much stronger quantitative meaning.

That does not make #314 a mistake. It is the opposite: the richer system made the next problem visible. Product interfaces often evolve this way. A more expressive layer creates new relationships, and those relationships expose conflicts that could not exist in the simplified version.

A shared deck multiplies good decisions and bad ones alike

By this point Cards was no longer a five-game experiment. The architecture described in our Devlog about Cards becoming Blupoli’s second product had grown into a shared engine and UI with game-specific domain modules. When we later added Baker’s Game by reusing the FreeCell family, the same principle showed up again: share the behaviour that is genuinely common and specialise only the rule that differs.

The card renderer is unambiguously common. A five of spades should not acquire one hierarchy in Spider and another in Klondike. Montana should not require a separate deck merely because its grid is denser. A shared renderer gives every solitaire the benefit of a visual improvement and keeps accessibility, drag behaviour, labels and responsive rules from drifting apart.

The cost is symmetry: a weak decision propagates just as efficiently. Increasing a corner suit too far does not create one local defect. It changes every game that uses the shared card component. A CSS exception scoped to one solitaire would have hidden the symptom without fixing the system.

That is why the final task reached beyond a couple of font sizes. It touched the renderer, shared CSS, the appearance preference model, translations, source tests and Playwright. The surface was shared by design, so the correction and its evidence needed to be shared as well.

“Make the suits bigger” was reasonable advice — until it overshot

The next stage, pull request #322, began from another valid observation: the cards looked slightly squat and the suit symbols did not have enough presence. We changed the base aspect ratio from 5/7 to 2/3, making the deck a little taller. Pips, ace marks and other suit symbols became more prominent. The same work added three face styles — Classic, Big suits and Minimal — plus four initial back styles: Blupoli, Violet, Coral and Midnight.

The end-to-end test from that revision captures the intention more clearly than any retrospective explanation could. It measured the corner rank and corner suit, then expected the suit to be larger than the rank. In other words, the outcome was not a random CSS side effect. We had taken “give the suit more prominence” and turned it into an automated expectation.

That expectation worked if the corner symbol was evaluated in isolation. It failed when the whole card became the unit of analysis. The number and the suit form one index, but the rank is still the primary answer to “which card is this?”. On number cards, the centre repeats the suit specifically to express the value. Once the auxiliary suit under the rank approaches the pips in visual weight, the eye has more work to do separating the index from the count.

Pull request #337 therefore did something important beyond a CSS tweak: it replaced the test expectation. The previous check rewarded a corner suit larger than the rank. The new one requires the rank to be at least 35% larger than the auxiliary suit, and it applies the same minimum separation between a central pip and the corner suit. The suite stopped protecting a previous implementation and started protecting the corrected design intent.

The 2:3 proportion was part of the hierarchy, not a separate polish pass

Changing 5/7 to 2/3 looks tiny when you inspect one card in isolation. Solitaire turns that geometry into a layout system. A Klondike column can stack many overlapping cards. Spider can expose ten columns. Montana compresses the deck into a very dense thirteen-position row. A few extra pixels of height change how much of a tableau fits before scrolling and how much space animations need.

That is why #322 did not update only .playing-card. The same aspect ratio was applied to empty slots, stack placeholders, Spider completion animations and victory card flights. The dense layouts already had their own width and overlap variables, because a card that looks comfortable in a seven-column desktop tableau cannot simply scale linearly into every other board.

The revision also introduced .card-hero-suit. It is shared markup that allows Big suits and Minimal to display a large central symbol without creating alternative card DOM trees. When a card is partially covered in a stack, that hero symbol can disappear unless it contributes useful information. In dense mobile layouts, a smaller central symbol can be restored to keep the suit recognisable after more detailed artwork has been suppressed.

Aspect ratio and hierarchy therefore became one problem. A taller card creates more room to separate the index area from the central composition and the reversed corner. The follow-up work was about using that room without filling it with symbols that all shout at the same volume.

Diagram of a five of spades split into three visual levels: a dominant rank, a smaller index suit, and five central pips with stronger visual weight
The final contract separates role and emphasis: the rank identifies, the small suit qualifies, and the five central pips communicate value.

The decisive fix was to name the index suit as its own thing

Before #337, the rank and its suit lived inside .card-corner, but styling the second piece depended on relatively generic descendant selectors. The fix introduced an explicit class: .card-corner-suit. That sounds like housekeeping, yet it gives a specific visual responsibility a stable name. We are no longer styling “the span that happens to follow the strong element”; we are styling “the auxiliary suit inside the index”.

The base CSS sets that suit to .58em relative to the index size and gives it its own vertical separation. Classic, Big suits and Minimal can adjust the relationship slightly without losing the hierarchy. The central pips remain larger and occupy a centre region that begins farther away from the corner index. The inverted corner follows the same rules at the opposite end of the card.

That semantic selector also makes future maintenance safer. If we later change rank tracking, the vertical rhythm of the suit, or the compression rules for narrow cards, each concern has a named target. We do not have to infer the meaning of a node from its position in the markup.

Semantic CSS can feel unnecessary in a small component. In a deck shared by thirteen games, three face modes, nine backs and multiple responsive states, explicitly naming the function of an element is a cheap way to stop a local adjustment from leaking into a different visual role.

Turning “this five looks like a seven” into reproducible evidence

Human perception cannot be fully reduced to a Playwright assertion, but we can protect the conditions that caused this specific ambiguity. The new browser test creates a deterministic Klondike state and puts a face-up 5♠ in a known tableau slot. It then checks three different layers of the card.

First, the DOM inside .card-pips--5 must contain exactly five .card-pip elements. This guards the renderer itself. Second, the card’s measured geometry must preserve the taller shape. Third, the browser’s computed font sizes are compared: both the rank and a central pip must be clearly larger than .card-corner-suit.

The test does not claim that every person on every screen will perceive the card identically. We do not have an automated metric for that. What it does prevent is the exact structural condition behind the problem: an auxiliary suit that becomes typographically equivalent to the symbols encoding the number.

The same E2E continues through the appearance control. It switches the face to Big suits and the back to Poker blue, checks the document attributes, confirms that the back motif is rendered, inspects the value stored in localStorage, reloads, and requires the preference to survive. It ends with the existing horizontal-overflow check. Hierarchy, customisation, persistence and responsive layout are connected in one scenario because together they form the visible contract of the deck.

Appearance belongs to preferences, not to the solitaire state

When #322 introduced configurable faces and backs, one architectural choice kept presentation from leaking into the game model. Solitaire state — cards, piles, foundations, moves, seed and victory — remains in the game repository. Deck appearance lives in browser-card-appearance-repository.js under blupoli-cards:appearance:v1.

That repository knows only two properties: face and back. It normalises unknown values and falls back safely when storage contains something invalid or a read fails. Switching from Classic to Minimal does not mutate a saved deal, invalidate statistics or create a rule variant.

The separation also lets the choice remain global across Cards. Pick a classic back in Klondike and the same back appears when you open Spider or FreeCell. The product keeps a coherent personal preference without forcing every domain module to know anything about decorative presentation.

This small boundary matters for future settings. A visual preference can evolve through its own accepted values and migrations. A rule setting, by contrast, belongs to the identity of a game state because it changes what moves are legal. The technical question is not whether both values happen to live in browser storage. The question is what each value means.

Three faces, one card tree

Classic, Big suits and Minimal are not separate renderers. A card is rendered once with its index, the appropriate centre content, .card-hero-suit and the reversed index. The root document carries data-card-face, and CSS decides which layers should be visible for the active presentation.

Classic keeps the normal pip layout, ace treatment and court art. Big suits hides those centre compositions on exposed cards and displays a very large single suit symbol for immediate recognition. Minimal removes more ornament, hides the reverse corner and simplifies border and shadow. All three preserve the same accessible card identity and the same pointer, keyboard and move behaviour.

Using one markup tree avoids two kinds of drift. Functional drift would happen if one face accidentally lost a drag attribute or an accessible label. Responsive drift would happen if a Montana-specific density fix reached one renderer but not the others. Keeping the structure shared makes the variants presentational rather than behavioural.

The #337 hierarchy correction therefore had to work across all three modes. Fixing only Classic because it is the default would have left the same semantic problem hidden inside the other presentations. The index suit remains secondary everywhere; what changes is the treatment of the centre.

Card backs exposed a less visible design constraint

The first redesigned back in #314 used CSS gradients. Blupoli’s global visual contract for Cards forbids them. Pull request #317 followed immediately, replacing the gradients with flat CSS geometry: borders, diamonds and rings assembled from pseudo-elements. It also added the no-gradient rule to the Cards-specific source test so the local lane would catch the same violation as the global suite.

That incident influenced every later back style. When #322 added Blupoli, Violet, Coral and Midnight, we deliberately avoided external images and gradient textures. Each design is built from colour variables and shared geometry. Even then, the first merge introduced another seemingly harmless convenience: color-mix() for derived transparent shades. The global Quality workflow caught it after the feature merged.

Pull request #324 removed every color-mix() call and replaced those values with explicit RGBA colours. The visual result remained, but the implementation now obeyed the project’s flat-colour contract. A Cards-only assertion was added for that rule as well.

This sequence is a useful reminder that “the feature PR passed its dedicated tests” is not the final definition of done. #322 cleared the Cards lane, but main still found a global incompatibility. The work was only genuinely closed once #324 restored a green full suite and the production deployment verified the corrected revision.

Five classic backs without copying a commercial deck

The second half of #336 asked for card backs that felt closer to familiar poker decks. We did not reproduce a branded Bicycle-style design or import artwork from an existing manufacturer. Instead we added five generic original patterns: Poker blue, Poker red, Classic diamonds, Classic lattice and Classic medallion.

All five reuse .card-back-pattern plus a small family of .card-back-motif elements. The same markup can become concentric frames, corner diamonds, crossed lines or a centred medallion through CSS alone. Colours use flat values and RGBA transparency. There is no remote SVG, raster texture, custom font or CDN dependency.

That keeps the backs inside the same technical boundaries as the rest of Cards. Content Security Policy needs no new host. Offline behaviour does not lose an image. Choosing a style does not trigger another asset request. Source tests can confirm that the five selectors exist while continuing to reject gradients and color-mix().

The previous four Blupoli-family backs remain available. The selector now groups Blupoli, Violet, Coral and Midnight separately from the five classic patterns. Nine choices are still represented by one stable value, which means old stored preferences continue to load and new values can be normalised without changing the game repository.

A visual preference still needs localisation

Adding technical values such as poker-blue and poker-medallion is only half the feature. The appearance selector is public UI, and Cards ships across six locales. #337 added the new group labels and back names for Spanish, English, Italian, Portuguese, French and German.

The persisted values are intentionally not translated. Storage keeps poker-blue; the interface decides whether the player sees “Poker blue”, “Poker azul” or the correct label for another locale. Changing language therefore does not invalidate the preference or force application code to parse presentation strings.

The Cards test suite checks that the new translation keys do not fall through to their raw identifiers. That does not judge the editorial nuance of each phrase, but it does stop a build from accidentally showing backPokerMedallion to players.

This is the difference between a CSS demo and a product feature. Once a setting becomes selectable, it needs a stable value, a localised label, keyboard focus, persistence, fallback behaviour, mobile layout and regression coverage.

Dense boards are where a pretty deck proves whether it actually works

A screenshot of one large card hides almost every difficult constraint in solitaire. Klondike has seven tableau columns. FreeCell and Baker’s use eight. Spider can require ten. Montana compresses the deck into thirteen positions per row. The index must remain useful long after the card has become much narrower than a desktop mock-up.

The Cards browser suite already visits all thirteen games and checks for horizontal overflow. It also measures tableau density and keeps targeted cases for seven-, eight- and ten-column layouts. Montana needs additional rules because cards can become so narrow that displaying a full pip arrangement stops being helpful. In that context the UI may suppress detailed pips and retain a simpler centre suit signal instead.

That does not contradict the five-of-spades requirement. Hierarchy is contextual. When there is enough room to display five pips, they should read as five and the corner suit should not visually join their count. When available width becomes extreme, the representation can degrade to a simpler signal as long as rank and suit remain identifiable.

Responsive design here is not uniform scaling. It is a priority system for information under pressure. This iteration made that priority clearer: first preserve card identity, then preserve richer ornament and count layout where the geometry supports it.

CI ended up becoming part of the visual design story

Continuous integration is often discussed as protection for logic, compilation and dependencies. In this sequence it protected the shape of a card back and part of the hierarchy of a card face. #317 exists because gradients violated the project contract. #324 exists because color-mix() did the same. #337 extended browser coverage so the relationship between rank, auxiliary suit and pips would not rely entirely on someone noticing it manually again.

Pull request #337 passed the Cards verification job, Cards browser E2E, the cards.blupoli.com Firebase preview and cache-policy validation. After merge, the full main Quality workflow was green, and the Firebase deployment workflow deployed Cards with production revision and cache checks succeeding.

None of that turns a font-size assertion into design judgment. Human inspection is still needed for balance, rhythm and recognisability. What automation can do is remove known failure modes from the set of things we repeatedly rediscover.

That distinction is useful beyond cards. Automating a visual decision does not mean design can be solved by numbers. It means numbers can defend the measurable parts of a design decision after the product has already established why they matter.

What changed for the player

From the outside, the result is much shorter than the implementation story. Cards now have a taller proportion. Central pips are easier to recognise. The rank leads the corner index, and the small suit qualifies it without competing. A five of spades has five central pips, while the two corner suit marks are visually distinct enough that they no longer behave like two extra members of the count.

The appearance panel offers Classic, Big suits and Minimal faces plus nine backs: four from the Blupoli visual family and five classic patterns. A chosen face and back survive reloads and carry across solitaire games. They do not alter rules, scores, statistics or saved deals.

For the codebase, the more important result is less visible. We still have one shared renderer. Presentation variants are activated through document data attributes. Appearance storage is separate from game state. The classic backs reuse shared motifs and stay inside the same CSP-friendly visual contract. The deterministic 5♠ test turns the perception bug that triggered the work into an explicit regression case.

That balance — more player choice without multiplying implementations — is the direction we want Cards to keep taking as the catalogue grows.

What this work deliberately does not solve

This is not an attempt to reproduce a specific physical deck or to claim historical authenticity. The “classic” backs are original geometric compositions inspired by common playing-card visual language, not copies of commercial artwork. We also did not turn the selector into a full theme editor with arbitrary colours, fonts and uploaded textures.

There is no automated perception test that guarantees nobody will ever misread a card. The size assertions cover the known cause and the pip-count assertion covers the renderer structure, but visual quality still requires inspection. Font rendering, display density and personal vision can all affect details a threshold cannot capture.

We also did not create separate decks for different games. That is intentional. As long as the same ranks and suits carry the same meaning, a shared deck gives Cards consistency and lowers maintenance cost. A future game that genuinely requires extra card information would need to justify that exception rather than creating another renderer because it is convenient.

The scope is therefore deliberately bounded: clearer hierarchy, useful personalisation, and a shared foundation that remains easy to reason about.

The broader lesson: bigger is not the same as more important

The five pull requests in this sequence leave a simple lesson. In #314 we wanted cards to read more like cards, so number faces gained real pip structures. In #322 we wanted suits to feel more prominent, and we went far enough to automate an expectation that the index suit be larger than the rank. The observation behind #336 exposed the limit of that direction. The symbol was more visible, but it had been promoted to the wrong level of the hierarchy.

The answer was not to shrink everything or revert the redesign. It was to separate jobs. The rank identifies. The small index suit qualifies. Pips communicate value. Aces and courts have their own compositions. Big suits and Minimal are allowed to emphasise a central symbol because they do so consciously as presentation modes. Card backs can be decorative because they are not competing with game information at all.

We also learned something about tests. An assertion can freeze a poor decision just as effectively as it can protect a good one. The #322 check was doing exactly what we asked; the problem was the expectation. #337 changed the test instead of treating it as inherited truth. A healthy suite requires us to revisit what each check is protecting when product evidence changes.

And the sequence reinforced why shared visual components deserve the same discipline as domain logic. A playing card is not “just CSS”. It is an interaction surface seen thousands of times across thirteen games and many screen widths. Once the card is treated as a system, a 5♠ that looked like a 7 can improve more than one face: it can improve the way the whole deck is designed, customised and verified.

You can see the result in Blupoli Cards. For the product context, the original Cards launch article explains why solitaire became its own Blupoli product. The Devlogs on Cards architecture and reusing the FreeCell family for Baker’s Game tell the same broader story from different layers: sharing a foundation works best when the differences that genuinely matter remain explicit.