Darshi Shah

← Part of LendSight

LendSight Design System: from Figma tokens to AI-generated production code

For LendSight, a benchmarking tool for community banks and credit unions, I set up a design system to make sure the UI stayed consistent through every phase of the AI-assisted build. The system kept the same rules and tokens applied across the product lifecycle, from the Figma designs to the Storybook documentation to the code that Claude generated.

Role
Product Designer, Design Systems
Context
Sole designer on LendSight, two surfaces sharing one visual language
Scope
Token architecture, component library, Storybook docs, AI workflow, dev handoff, accessibility rules
Tools
Figma, Figma MCP, Storybook, Claude Code, React
LendSight Design System: the same component carried from Figma variables through Storybook documentation to AI-generated React code.
The thesis in one image: the same component, designed, documented, encoded as a rule, and shipped.
Overview
Context

Building Consistent UI Through an AI Loop

LendSight is a benchmarking and predictive modeling platform for community banks and credit unions, and I designed the product from 0 to 1. I was the only designer on the team, and most of the front-end code came from an AI build loop that went from Figma to Claude Code to React. The loop moved quickly, but it could only be as consistent as the rules it was given, so I built the design system to write those rules down in a form that both people and models could follow.

01 Figma variables Three tiers of tokens, bound to component properties.
02 Token spec Variable name, CSS custom property, usage rule.
03 Storybook Variants, states, props, usage rule per component.
04 · differentiator Claude skills Token rules + component selection logic the build loop loads.
05 React code Components where every style names its token.
Problem

Undocumented Rules and Hardcoded Colors

As the product grew, I kept running into the same three problems. The root cause was the same across all three: because values were hardcoded rather than assigned a role, there was no way to tell why a button was that blue, only that it was.

1

Rules Lived in My Memory

Screens matched each other because I remembered the rules, not because the rules were documented, which meant a second contributor, whether human or AI, could not have reproduced my decisions.

2

AI Hardcoded Appearance

When I asked for a blue button, the model wrote #2a60f5 directly into the code. After ten screens I had ten slightly different blues with no way to re-theme any of them.

3

Tokens Did Not Survive Handoff

Tokens kept breaking on the trip from design to build. Figma imports mapped them inconsistently, and I found myself manually fixing the same padding and type drift in every prototype.

Drift exhibit — primary button BEFORE TOKENS
SAME COMPONENT · TWO SCREENS
AS WRITTEN IN CODE
Continue
Continue
fill #2a60f5
radius 6px
padding 10px 18px
fill #2f66ff
radius 8px
padding 12px 22px
// PrimaryButton.jsx
background: '#2a60f5',
borderRadius: 6,
padding: '10px 18px',
// CtaButton.jsx
background: '#2f66ff',
borderRadius: 8,
padding: '12px 22px',

Same button, two files. No shared token — each screen hardcoded its own hex.

Before tokens: the same primary button, hardcoded twice. Two files, two blues, two radii, two paddings, and no shared token to reconcile them.
Solution

One Set of Rules from Figma Down to Code

To fix this, I named every value in the system for its role instead of its appearance, and then carried that structure through every layer of the workflow: the Figma variables, the Storybook documentation, the Claude skills, and the generated code.

Token Hierarchy: From Raw Hex to a Component-Ready Token

I organized the tokens into three tiers. Foundation tokens like #2A60F5 are raw primitives and never touch components. Brand tokens like Color/Brand/500 exist only to define semantic tokens. Semantic tokens like Colors/button/primary/default are the only tier that components are allowed to use.

One rule governs the whole hierarchy: always pick the token that matches the role, not the appearance. Colors/button/primary/default happens to resolve to #2a60f5 today, but if the brand changes tomorrow, every reference updates in a single change.

TierExampleUsed where
Foundation #2A60F5 Raw primitives. Never referenced inside a component.
Brand Color/Brand/500 Defines semantic tokens only. Never touched directly by a component.
Semantic Colors/button/primary/default The only tier components touch.
Three tiers. A component may reference the semantic tier and nothing above it.

The system also defines an order of operations. Surfaces are chosen first and content second, so the correct text token always follows from the surface underneath it. State tokens for hover, selected, and disabled bind only to pseudo-states, and a separate accent palette handles category tags so that it stays walled off from the core interface colors.

Two Figma variable collections traced by one alias chain: the button/primary/default semantic token points back to Color/Brand/500, which resolves to the raw hex 2A60F5. Three numbered callouts mark the Foundation, Brand, and Semantic tiers.
One alias chain across the tiers: Colors/button/primary/default (3, semantic) resolves to Color/Brand/500 (2, brand), which resolves to 2A60F5 (1, foundation). A component only ever names the semantic tier.
Figma variableCSS custom propertyUsage rule
Colors/button/primary/default--colors-button-primary-defaultPrimary CTA fill. One per view.
Colors/text/on-primary--colors-text-on-primaryText on a primary surface. Follows the surface, not the brand.
Colors/surface/card--colors-surface-cardCard and panel background. Chosen before its text token.
Colors/button/primary/hover--colors-button-primary-hoverBinds to :hover only. Never a static fill.
Space/space-*--space-*The full spacing scale. Every padding and gap steps through it. Never raw px.
Radius/radius-*--radius-*The full radius scale. Every rounded corner uses a step. Never raw px.
Accent/tag/*--accent-tag-*The full category-tag palette (violet shown). Category tags only. Walled off from product chrome.
A slice of the token spec across color, spacing, radius, and state. Semantic roles carry a per-token rule; scales and palettes (spacing, radius, tags) carry one rule for the whole family.

Component Selection: When to Reach for What

The token rules solved consistency in color and spacing, but component choice had the same problem: I knew which component fit a situation, but that knowledge lived in my head. So I wrote a selection guide that starts from user intent instead of a catalog, matching what the user is trying to do to one specific component. Narrowing a table calls for a filter chip. Switching between page sections calls for tabs. A persistent warning gets a banner, and a transient confirmation gets a toast.

For the harder calls, I wrote the reasoning down as explicit rules. The dashboard stat tile, for example, is placement-driven and appears only in the top-of-dashboard KPI row, while the same number anywhere else becomes a card with an H3.

I also documented the gaps. Status badges are currently assembled from existing tokens using a transparent fill with a status-colored border and text, and they are flagged as a known gap. The boundary between chips and tabs is still unresolved, so the written rule there is to ask rather than guess.

Examples of User intentReach forRule
Narrow a table to a subsetFilter chipNarrows one dataset. Does not switch sections.
Switch between page sectionsTabsChanges what is on screen, not what is filtered.
Show a headline KPI in the dashboard summary rowStat tileTop-of-dashboard KPI row only.
Show the same number anywhere elseCard + H3Placement, not appearance, decides.
Persistent warning that must stay visibleBannerStays until dismissed or resolved.
Transient confirmation after an actionToastAuto-dismisses. Never carries required actions.
Status label on a row or cardBadge assemblyKnown token gap: outline + status text, no dedicated token yet.
Boundary between chip and tab is unclearAskMarked unresolved. Rule is to ask, not guess.
An excerpt of the intent-to-component guide. Judgment written down, including the gaps.

Here is the same guide applied. Every component in the shipped dashboard below was selected by one of these rules.

LendSight bank-facing dashboard: a top row of KPI stat tiles, filter chips beneath, and a rate-distribution chart.
The shipped dashboard, reused from the LendSight case study. Every component here follows a written selection rule: the stat tiles in the top-of-dashboard KPI row, the filter chips that narrow one dataset without switching sections, and the badge assembly that fills the documented token gap.

View LendSight product case study

Additional info
Process

Building the System from the Bottom Up

The work moved through four sequential passes: audit, tokenization, documentation, and encoding. Each pass produced the input for the next one, so the audit fed the token spec, the spec fed Storybook, and both of them fed the Claude skills.

Pass 01 Audit Inventory every screen and tag each element by role.
Pass 02 Tokenize Restructure tokens into three tiers in Figma variables, bound to component properties.
Pass 03 Document Capture components, variants, states, and usage rules in Storybook.
Pass 04 Encode Turn the rules into Claude skills the build loop loads: token discipline, component selection, accessibility, and code-quality review.

Storybook as the Single Source of Truth

In this setup, Figma is where the system is designed and Storybook is where it is consumed. Every component page carries its variants, states, props, and the usage rule from the selection guide, which means designers reference Figma variables, developers reference stories and CSS custom properties, and both sides read from the same underlying tokens.

A Storybook page for the Button component, showing the rendered variants in the canvas alongside the controls and documentation.
The Button component in Storybook: its variants, states, and props documented on one page, all reading from the same tokens the designers reference in Figma.

Turning the Rules into Skills Claude Reads

To make the system enforceable, I put together a set of skills Claude Code loads before generating any LendSight UI. I wrote the component-discipline skill from scratch; it checks that both the components and their tokens stay consistent as new screens and components get generated, catching drift a token check alone would not see. The token skill started from one I found online, edited to teach this system's tier rules and block the failure modes I saw in early output: hardcoded hex, brand tokens inside components, hover tokens applied as static fills. The accessibility skill and the code-quality review skill are both adapted from public skills: the accessibility skill enforces the contrast and state rules described below, and the code-quality skill checks the generated output against all of these before it ships.

All four skills enforce traceability. Every generated style names its token; every component names its catalog entry.

token-skill.md · excerpt
## Rule: pick by role, not appearance
Never write a hex value. Resolve every colour to a semantic token.
Semantic tokens are the ONLY tier a component may reference.

# Common mistakes → corrections
✗ background: #2a60f5
✓ background: var(--colors-button-primary-default)
✗ Color/Brand/500         (brand tier — never in a component)
✓ Colors/button/primary/default
✗ hover token used as a static fill
✓ hover binds to :hover only

Writing the skills turned out to be a useful test of the system itself. Whenever a rule was too vague for a model to follow, it usually meant the rule had never been properly defined in the first place.

With and Without the Skills

The same button, generated two ways. Without the skills, raw hex and no trace of intent. With them, CSS variables, token comments, and a component annotation that points back to the catalog.

without the skills
<button style={{
  background: '#2a60f5',
  borderRadius: 8,
  padding: '10px 16px'
}}>
  Run model
</button>
with the skills
{/* Component: Button / primary → catalog §2.1 */}
<button className="btn-primary">
  /* background: var(--colors-button-primary-default)
     radius:     var(--radius-md)
     padding:    var(--space-3) var(--space-4) */
  Run model
</button>
Building

The Loop That Caught Its Own Mistakes

Each build cycle ran the same loop. I designed in Figma against the token variables, pulled the frames through Figma MCP, generated React with the skills loaded, and reviewed the result against Storybook. Every cycle was a test of whether the rules held up under a builder that follows instructions literally.

Review got faster over time, because every mistake had a name and a rule behind it. A raw hex value broke a token rule. A stat tile on a detail page broke a placement rule. Instead of debating whether something looked right, I could just point to the rule it violated.

Figma
filter / Product
Product CD
Storybook
ProductCD Default
ProductCD Active
ProductCD Hover
ProductCD Disabled
PropTypeDefault
stateenumdefault
activebooleanfalse
disabledbooleanfalse
Build
FilterChip.tsx
1234567891011121314
import { forwardRef } from 'react';
export const FilterChip = forwardRef((props, ref) => {
const { category, value, icon, active = false, disabled, onSelect } = props;
return (
<button ref={ref} disabled={disabled} onClick={onSelect}
className={cx('rmt-chip', active && 'rmt-chip--active')}>
<span className="rmt-chip__icon">{icon}</span>
<span className="rmt-chip__cat">{category}</span>
<span className="rmt-chip__value">{value}</span>
<ChevronDown className="rmt-chip__chev" />
</button>
);
});
Triple view: one filter chip across Figma, its Storybook states and props, and the FilterChip source in the shipped Claude Code build. Alignment between the three is the whole message. All three read from the same tokens, so there is no translation step in between.
Accessibility

Building Accessibility into the Tokens

Accessibility is built into the token structure itself rather than checked at the end. Surface and text tokens are paired by role, so a failing combination like dark body text on a primary button fill isn't something a screen has to be caught doing, it's something the tokens prevent. Hover, focus, and disabled states are tokens too, so generated components inherit correct behavior instead of inventing their own.

Type roles help too: helper text and metadata have a defined floor of 12px medium, so nothing dips below it ad hoc.

Surface tokenApproved text tokenPairs
button/primarytext/on-primary
button/primarytext/body✗ fails contrast
surface/cardtext/body
surface/cardtext/muted✓ (≥ 12px only)
surface/subtletext/body
Surface tokens against approved text tokens. The one failing pair is kept in the matrix because the rule exists because of a real failure.

To check this against real standards, I run a public accessibility skill that tests WCAG 2.2 requirements across contrast, keyboard navigation, and screen reader support. The honest limit: the system has not been through a formal WCAG audit yet. Contrast pairs and focus behavior are enforced by rule and checked by the skill, not yet verified by a full audit. That audit is the first item on the roadmap; the skill closes a lot of the gap in the meantime.

Reflections

Writing for Models Made the System Clearer for Humans

Some parts of the system stayed intentionally loose, like chips versus tabs, because forcing a premature decision would have been worse than leaving it open. I'm working on closing these as inconsistencies surface and the use cases become clearer.

The system hasn't been through a formal accessibility audit yet. The skill helps, but it isn't a substitute for one, and a full audit is on the roadmap.

A design system built for an AI-assisted workflow has different requirements than one built for humans, since people fix inconsistency without being told and models don't. That changed how I wrote and thought about instructing AI. I stopped writing rules the way I'd explain something to a teammate, and started writing them the way I'd explain something to someone who takes everything at face value, since every rule would be followed literally, not interpreted. That meant fewer implicit assumptions and more explicit edge cases, even the ones that felt too obvious to write down at first.

There are gaps in this system I know about, and there are probably ones I haven't found yet. I look forward to working on it to bring it to a state where it is most productive and does what it is intended to.

View LendSight product case study