Skip to main content

Design system study

Rathe UI

A component system for a trading card game estate, and a live WebGL scene to put it against. I built it as a study of Legend Story Studios, mostly because they ship across a Django tool, a React card database, and a WordPress site at the same time, and that is a harder problem than one app.

StorybookComponents, states, a11y checksThe sceneOne HTML file, live WebGLSourceTokens, components, tests

How this was built

I used AI heavily to build this. I am saying that up front because you would probably notice anyway, and because the part I want judged is not the typing. It is more about which problem to pick, what the work had to pass before I kept it, and reading back what came out.

The gates below are the clearest example. I wanted the accessibility claim to be something you can run rather than something I say, and on their first runs they failed and found real problems in my own work. The scene is a study, and I think the components are the more useful part to look at.

Decisions worth defending

Eighteen components. I picked them because each one has an accessibility problem that is actually hard, not to get to a round number.

Tokens are CSS custom properties, not a JavaScript theme object

The estate is not one app. The tournament tool is server rendered Django with Bootstrap and jQuery, the card database is a React SPA, and the marketing site is WordPress. None of them can share a JS theme object without a rewrite first, but all three can read a CSS custom property today. So the React components sit on top of that layer instead of beside it, and a Django template and a React component that both ask for the danger colour get the same value.

Brand red and error red are different colours

Vermilion is the identity colour of the game. If error states borrow it then every destructive dialog looks on brand and every primary button looks like a warning, so danger gets its own darker, browner red. They are separate tokens on purpose, and the contrast gate holds both against their own label colour.

Pitch values are tokens, and never colour on its own

In Flesh and Blood the pitch value is printed as a coloured strip: red gives one resource, yellow two, blue three. That is data rather than decoration, so it lives in the token layer instead of being written by hand in each component. Every pitch chip also renders the numeral and carries a name like 'Pitch 1 (red)', because roughly one in twelve men has some colour vision deficiency and pitch is the most basic fact about a card.

The select is a native select

A custom listbox is one of the biggest accessibility liabilities a design system can take on, and the native control is better on mobile and with screen readers. It is styled with appearance:none and a chevron drawn in CSS. The cost is that the option list cannot be styled, which I think is worth it here.

Dialog is built on the native dialog element

showModal gives the top layer, an inert background, and Escape handling without reimplementing any of it. What the platform does not give is focus restoration, so the component stores the active element before opening and puts focus back on close. That is the one that turns into a support ticket, because a scorekeeper closing a dialog mid event loses their place in the page.

Toasts use two live regions, both created at mount

A live region cannot change politeness after it has been announced, so polite and assertive need separate containers. Both are in the DOM from mount and stay empty until needed, because screen readers do not reliably announce content that arrives at the same moment its live region does. Dismiss timers pause on hover and on focus, and a danger toast never dismisses itself.

Accessibility, as a build step

A token file is where a contrast regression hides, because someone nudges a colour to look better in one theme and quietly breaks the other one. So I put a gate on the palette. It reads the token file, resolves every declared pair in light and dark, and exits non zero if any pair misses its WCAG 2.2 target.

  ok   light 18.29:1 (needs 4.5)  body text on page
  ok   light  6.98:1 (needs 4.5)  label on primary button
  ok   light  5.90:1 (needs 4.5)  pitch 1 numeral
 FAIL  light  1.95:1 (needs 3)    input borders (SC 1.4.11)
 FAIL  dark   2.25:1 (needs 3)    input borders (SC 1.4.11)

36 pairs checked across 2 themes.
2 contrast failure(s).

That is the first run, not something I put together afterwards. The border colour on text inputs was sitting at 1.95:1 against the page, well under the 3:1 that WCAG 2.2 asks for on non text contrast. It is 3.27:1 in light and 3.46:1 in dark now, and the gate runs in the pipeline so it cannot drift back.

A second gate walks all 113 stories in the built Storybook, in both themes, because checking a component on its own is not the same as checking what a consumer renders. It found a token I had exempted myself. The faintest ink was held to the large text threshold on my own note that it was only used for display sized timestamps, and it was actually being used at 12px for card class and stat labels. An exemption is a promise about how a token gets used, and 25 call sites is too many to keep that promise by hand, so it is held to 4.5:1 now like everything else.

The check that reported nothing

The scene passed its accessibility tests, and that did not really mean much. axe cannot judge contrast for text sitting over a WebGL canvas, because it has no way to know what colour is behind the glyphs. It does not fail those elements. It marks them incomplete, and I was only checking for failures. Thirty seven elements were in that state, which was most of the text on the page.

So I measured it instead. A script makes every glyph transparent, screenshots the page so the image holds only what is actually behind the text, reads that image back in as pixels, and then for each text run samples every pixel inside its box and keeps the one that gives the worst contrast. I keep the worst one because an average hides the moon passing behind one corner of a paragraph.

The first run put 77 of 233 text runs under their threshold. The footer was the worst of them at 2.78:1, which is the part you notice by eye. The chapter rail measured 1.03:1 against the moon, and the wordmark measured 1.04:1 where it crosses a brazier. Fixing it took four colour changes and five scrims, checked against the composited pixels each time. All 233 runs clear their threshold now.

What the scene costs

The whole scene is procedural. No textures, no models, no video, no external requests. Geometry, materials, and every texture are made at runtime, which is why a full 3D environment fits in one file that is smaller than a typical hero image.

Measured performance of the WebGL scene
MetricMeasured
Transfer size, uncompressed702 KB
Transfer size, gzipped201 KB
First contentful paint148 ms
DOM interactive123 ms
Load complete438 ms
Frame rate while scrolling, average60.2 fps
Slowest single frame59.5 fps
JS heap, desktop viewport10.1 MB
JS heap, 390 x 844 viewport18.4 MB

Measured 16 August 2026 in Chromium on an Apple M1 Pro. Two caveats belong with the numbers. The mobile row is a desktop browser at a phone viewport with a device pixel ratio of 1, so it does not stand in for a real handset, and a machine this fast will hold 60 fps on work that would drop frames on a mid range Android. What I can say is that the frame budget is stable and the payload is small, not that it has been proven on constrained hardware.

Pipeline

The pipeline runs type checking, unit and accessibility tests, the contrast gate, the story audit in both themes, and a Storybook build, cheapest first so a type error fails in seconds instead of after a four minute build. There is no branch allowlist on the trigger. Everything quoted on this page came from running those checks rather than from describing them.

That last detail is not a preference. While building this I found that the end to end workflow on this site had never actually run, for two reasons at once. The trigger listed main and develop while the branch I work on is master, so pushes never matched. Underneath that, the repository default branch on GitHub still pointed at develop, which was abandoned in June 2025, sits 133 commits behind, and has no .github directory in it at all. GitHub registers workflows from the default branch, so the repository reported zero workflows and every run died at startup before reading a step.

If I had only fixed the trigger it would have looked fixed and still not run, because green and never ran look the same in a repository list. So a pipeline can be broken in more than one place at once.

What is missing

  • No visual regression testing. Storybook plus a snapshot runner would catch the kind of change the contrast gates cannot see.
  • No real device lab. The performance numbers need a mid range Android before they mean much.
  • The set covers primitives and a few domain pieces. A real estate system would want forms at scale, denser data patterns, and print styles for tournament sheets.
  • Tokens are not published as a versioned package yet, so a Django consumer would be copying a CSS file rather than depending on one.

An independent study by Joey Hipolito. Not affiliated with, endorsed by, or produced for Legend Story Studios. Flesh and Blood and all related names are the property of Legend Story Studios. Game and tournament facts here come from their public rules and product pages. Back to the site