The same studio page shown six times, once in each starting personality: dark and light, gold, mauve, green, vermilion and terracotta, with serif, italic and condensed titles.
The same studio page in the six starting personalities.

Design system

Eclipse UI

One shared base, each studio's personality.

Eclipse is a booking platform for yoga, Pilates and training studios. Each studio has its own instance (about nine today), and all of them come from the same code. Eclipse UI is the design system I made so each one looks like its studio without forking that code and without lowering the quality.

System design, interface design and development  ·  One person


Role
System design, interface design and development, by one person
Stack
Rails 8, ViewComponent, Stimulus and Turbo, Tailwind 4. No React and no JavaScript build step.
Result
58 components (47 in the base, public on the site, and 11 in the booking pack that lives only in Eclipse), 6 starting personalities and a catalog with 50 live entries (42 from the base and 8 from the pack). Today it shapes the public site of the Eclipse demo.
Links
Eclipse demo ↗ · Eclipse UI site ↗ (in Spanish)

The problem

A barre studio and a HIIT box shouldn't look the same, but in Eclipse the only thing that changed from one instance to another was the brand color. Views wrote their colors by hand: across the 17 public views there were 442 loose references to variables ([var(--color-…)]), 207 Tailwind palette classes (gray-500, white…) and 48 icons pasted in as <svg>. Changing a studio's voice or shape meant editing view by view, in every instance.

On top of that, the base brand color, a gold (#a3946b), was used as text and as a button background: it measured between 2.7 and 3.0:1, and WCAG AA asks for 4.5:1. Across five pages, axe found 58 elements with insufficient contrast.

What I needed was a system that separated two things: what every studio deserves equally and what makes each one different.


The idea: base and personality

The base is what doesn't change from one studio to another, and if a component breaks one of these guarantees, it isn't done: truly accessible (measured AA contrast, visible focus, full keyboard support, reduced motion respected); only tokens and scales, never a loose color or size; hierarchy through layers and 1 px borders, with shadows saved for what floats; physical motion; integrated with Rails (controls send their value with name:, work with Turbo and with the form builder), and code that belongs to whoever uses it, with no black boxes.

The personality is what each studio decides, in five traits:

TraitWhat changes
Voicetypefaces, weight, italics, uppercase and width of the titles; the voice of the buttons
Shaperoundness, pill or square buttons and fields, how marked the borders are
Colorbrand, ink, paper and how much the brand tints the grays
Airhow compact or airy the interface is, how much shadow floating things get
Rhythmhow fast everything moves and how much the important things glow

The traits move with 27 dials that fit in one theme.css block. The engine derives the rest: the palette, the hover states, the text on the brand, the layers, the shadows, the radii and the durations. I started from six personalities designed for different kinds of studios, and each one is a starting point, not a closed theme:

PersonalityForCharacter
Carbónsober interfacesmonochrome, Inter and JetBrains Mono, 1 px borders
Eclipsethe app's basewarm gold, Fraunces for titles, calm
Albaspa, barre, wellnessmauve, Cormorant in italics, everything in pills, lots of air, slow
Brisaclinical Pilates, physical therapysage green, Manrope, small radii, compact and fast
Umbrabox, HIIT, cyclingvermilion on black, Archivo condensed in uppercase, everything square
Terrayoga, communityterracotta, soft Fraunces and Nunito, the brand tints the grays

Bringing one to a studio means copying its block into theme.css and moving the dials it needs. Everything else realigns on its own.

See it live

The same class card in three of the six starting personalities. The card's code doesn't change, only the dials.

Personality

60 min

4/12

Vinyasa Flow

with Ana GarcíaAll levels

8 spots left

Eclipse. Warm gold on paper, Fraunces for titles, calm. For warm, editorial brands.

Alba. Mauve, Cormorant in italics, everything in pills, lots of air, slow. For wellness, beauty and editorial brands.

Umbra. Vermilion on black, Archivo condensed in uppercase, everything square, fast. For sport, energy and high-contrast brands, shown in dark, its natural mode.


The guardians: so the base doesn't depend on memory

A system that promises AA contrast in any combination of dials can't be checked by eye. So the base has guardians that run with the tests:

  • Contrast. A list of 22 text and surface pairs (config/eclipse_ui/contrast.yml) is measured in a real browser in the six personalities and in the studio's theme.css, in light and dark: 308 measurements in 0.6 s. A browser measures them because the engine derives colors with color-mix(in oklab) and oklch(from …), and reimplementing that in Ruby would be a second copy of the engine that sooner or later would say something different from the screen. If a studio picks a brand color that makes some text unreadable, the test says which one.
  • Tokens. Eight rules go through every component and its CSS and fail, with file and line, if a hex shows up, a Tailwind palette color, a shadow that doesn't follow the dial, a text size in px, a rounded off the scale or a hand-written variable where there's a named utility.
  • Catalog. Every component has an entry, a demo, props, an ARIA pattern and keyboard support. A piece can only say "stable" if a real view in the app uses it.

Contrast stopped being an aesthetic decision: brand-colored text uses text-primary-text, a derived tone that passes AA in every personality (5.7:1 at minimum), and the text on a brand button is picked automatically (6.0:1 on the gold).

The tokens, live

Everything below is rendered by the same engine, with the Eclipse personality.

Color

Brand, ink, paper and tint go in. Surfaces, text, borders and states come out, mixed in oklab.

Dials

  • Brand--eui-brand · #a3946b
  • Ink--eui-ink · #1a1a18
  • Paper--eui-paper · #f5f2ec
  • Tint--eui-tint · 25% brand in the grays

Surfaces and lines

  • Background#f5f2ec
  • Card#ffffff
  • Raised#edeae3
  • Muted#e8e5df
  • Border subtle#e2dfd9
  • Border#d6d3cc
  • Border hover#c4c1b9

Text and brand

  • Foreground#1a1a18
  • Muted text#66635a
  • Faint#87847b
  • Primary#a3946b
  • Primary hover#b9aa80
  • Primary text#6a5c35
  • On primary#181612

States

  • Success#4a7357
  • Warning#b88a3a
  • Danger#b04236

Type

One text font for the whole interface, a mono for data and labels, and each personality's display voice for titles. If a size isn't on the scale, it doesn't exist.

  • DisplayFraunces · 48pxBreathe, move, come back to you.
  • DisplayFraunces · 36pxVinyasa Flow
  • Page titleInter · 24px · 600Today at the studio
  • Section titleInter · 18px · 600Packages
  • BodyInter · 14pxBook your spot in Vinyasa Flow with Ana García.
  • MetadataInter · 12px8 spots left
  • Data and labelsMono · 11px07:00–08:00 · Room 2 · #R-1042

Spacing

One density dial, from 0.85 to 1.2, multiplies padding, gaps and control heights. An airy brand breathes; a compact one tightens.

  • Padding sm12px
  • Padding md16px
  • Padding lg24px
  • Control sm32px
  • Control md40px
  • Control lg48px

Radii

One roundness dial multiplies every radius, so the whole app rounds or straightens with the brand. A personality can turn buttons and fields into pills, or square them off, without touching cards.

  • sm6px
  • md8px · buttons
  • lg12px · fields
  • xl16px · cards
  • 2xl20px · panels
  • fullpills

Physics without React

Components that feel alive usually come from React libraries. Eclipse is Rails with Stimulus, so I wrote the physics from scratch: spring.js is 127 lines with no dependencies, with a fixed-step integrator (1/240 s) that behaves the same on a 60 Hz screen as on a 120 Hz one.

Chart of three springs reaching 100% of their target over time: responsive at 192 ms with a 0.9% bounce, snappy at 300 ms and gentle at 550 ms, both without bounce. Labels are in Spanish.
How each spring reaches its target.
  • Five named presets instead of durations: snappy gets within 1% of its target in 300 ms (buttons, indicators), gentle in 550 ms (tabs, dialogs) and responsive in 192 ms, with a 0.9% bounce saved for returns and toast stacks.
  • It keeps its velocity. If the target changes mid-way (a drag, a magnetic button that follows the cursor), the spring doesn't restart: it keeps going from where it was. That's the difference between something that feels physical and something that feels animated.
  • It obeys the studio and the person. The rhythm dial (--eui-motion) scales the time of every spring and of the CSS transitions, and with prefers-reduced-motion everything jumps to the end.

Today 34 Stimulus controllers use it: slide to book, rolling digits, tabs with a sliding pill, modals that settle, toasts you can drag away.


In the real app: the demo's public site

The real test was dressing the demo's entire public site in the components: navigation, footer, notices, confirmations, home, schedule, class, pricing, contact, sign in, sign up, account and About. The screenshots use the same database in both versions; only the code changes.

The demo studio home before Eclipse UI, a dark hero with loose cards, and after, a clean hero with a capacity meter.
Home, before and after.
The weekly schedule before and after: the new one shows each class status with a people icon and spots left.
Schedule, before and after: each class status with the people icon.
The demo studio's package pricing before and after: the new cards add cost per class and validity.
Pricing, before and after: cost per class and validity on each package.
The demo studio home and a class on a phone, before and after.
On the phone: home and class.
A class page on a phone, before and after: the new one puts capacity and the action in one card.
The class on the phone: capacity and the action in a single card.

What changed in the code of the public views:

MetricBeforeAfter
Lines of ERB1,6951,470
Hand-written colors ([var(--color-…)])4420
Tailwind palette classes (gray-*, white…)2070
Tailwind shadows that don't follow the dial240
Pasted <svg>480
Eclipse UI components used419

What changed in accessibility (axe 4.10, WCAG 2.1 AA, on home, schedule, class, pricing and sign in, on desktop in light mode):

Insufficient contrastBeforeAfter
In the content (the gold as text or background, grays on paper)480
In the dark footer (faint text on black)100
Total580

The footer was the last to fall: its labels and credits used text-faint, at 3.66:1 on black, and the system itself says text-faint isn't for text that has to be read. Now they use text-muted-foreground. On the phone and in dark mode, axe still flags small labels in the schedule and the class with that same tone (from 3.4 to 4.3:1); that's the next thing to align.

Four of its components, live

The public catalog has 42, each with a live demo, props, an ARIA pattern and keyboard support. These four, with their states, in the Eclipse personality.

Button with states

It tells you what's happening with the action: it spins while it books, draws a check when it's done, and if something fails, it shakes and says so.

  • Idle
  • Loading
  • Success
  • Error

Text field

The field takes care of the label, the help text, the error and the ARIA wiring. Units and icons sit inside it.

Default
Focus
With units

10-class pack

Error

Reformer: 45 min minimum

Status pill

One look for the status of orders, payments, bookings and subscriptions. The color never goes alone: the status always reads as text.

  • Confirmed
  • Pending
  • Cancelled
  • No-show
  • Active
  • Expired
  • Sold out
  • Paid
  • Refunded
  • Live

Empty state

What a table, a list or a section shows when there's nothing yet: it says what's happening, what to do, and offers the action.

No classes this week yet

Schedule the first one from a template and it will show up on the studio's schedule.

Cancelled bookings0

No cancellations this week

When someone cancels, you'll see it here with their credit returned.


From internal system to product

Eclipse UI was born inside Eclipse. For it to work outside the app, it had to stop being part of it.

  • Its own repository. I split Eclipse UI from the app into its own repository, as a Rails gem (eclipse_ui). Eclipse uses it as a read-only copy that updates with one command (bin/update_eclipse_ui), and tests prevent editing it inside the app. The booking pack (class, schedule, packages, booking…) stayed in Eclipse: it's what sets it apart from other studio software.
  • From documentation to product page. The public site went from documentation to a product page, under the idea "Don't let your app look AI-made". At the top, the same page switches on its own between the six personalities. The motion can be paused (WCAG 2.2.2) and doesn't start on its own if the person asks for reduced motion. Below are still the catalog, System and Personalities: a static export with the demos working without a server, which doesn't publish the components' code, only how to use them.
  • Outside Rails. From the site you can download for free, with no account: the engine in a single CSS file (eclipse-ui.css) and, for each personality, its theme.css, its shadcn.css and its DESIGN.md.
  • The shadcn.css is measured. It carries shadcn/ui's values measured in a real browser on top of the engine and converted to OKLCH. The reason is the same as with contrast: measure what the screen paints instead of reimplementing the engine.
  • The DESIGN.md is for the AI. It's a set of instructions for an AI agent (Claude Code, Cursor) to apply the personality on every screen: which tokens to use, which rules to follow and what would make the app look AI-made.
The Eclipse UI site home: the headline
The Eclipse UI home: "Don't let your app look AI-made".
The Eclipse UI catalog: a grid of component cards, each with its live demo.
The catalog: every component with its live demo.
A component page on the Eclipse UI site, Slide to book, with a stage that switches personality and mode.
A component's page: the stage switches personality and mode.

What I learned

  • Naming the traits was more useful than adding dials. With "voice, shape, color, air and rhythm", a studio owner can say what they want to change without talking about CSS, and every request lands on a specific dial.
  • A guardian is worth more than a rule in a document. The token and contrast rules run with the tests: a component that breaks them doesn't pass, whether a person or an AI wrote it.
  • The guide is for the AI too. The same guide (docs/GUI.md) and a set of instructions for the agent that writes the UI make new code come out with tokens, physics and accessibility from the start.
  • An internal design system becomes a product when it works outside its app. And today, the people who most need a design personality are the ones building with AI.

What's next: bring the studio dashboard (what the owner uses every day) to Eclipse UI and test in real Safari and Firefox.

How it was measured: the screenshots and axe 4.10.3 ran in Chromium with Playwright, at 1440 × 900 and 390 × 844, against Eclipse's commit 99cdd64 (before Eclipse UI) and against main, with the same database. The code count runs the Ui::Guardrails rules over the same public views in both commits (17 files before; 19 after, because two partials came out of them). The spring timings simulate spring.js with its own integrator and a 100 px travel.