Tokens
Design tokens are design decisions stored as variables. They separate structure from visual style, so switching themes or updating a brand color cascades across every component without touching individual files.
Senco defines 306 tokens in ds.css, organized into six layers. Those 306 names produce 497 --ds-* declarations once the dark theme and the prototype scope redefine them, plus 41 framework bridge variables — 537 declarations in total. Three numbers, three different questions; tokens.json reports all of them. Every layer references the one below it and never skips downward — that single rule is what makes a brand change, a theme switch, or a platform swap a one-file edit instead of a search-and-replace.
Token layers
| # | Layer | Contains | Example |
|---|---|---|---|
| 1 | Base | Raw values. No meaning attached. | --ds-accent-600: #0F62FE |
| 2 | Semantic | Intent-based aliases. What a value is for. | --ds-primary: var(--ds-accent-600) |
| 3 | State | Transformations of semantic tokens for interaction. | --ds-primary-hover: var(--ds-accent-700) |
| 4 | Component | Scoped to one component. Composes semantic + state. | --ds-actionbtn-primary-bg: var(--ds-primary) |
| 5 | Platform | Resolves theme, motion preference, and viewport. | .ds-layout.ds-dark { --ds-primary: #458DFF } |
| 6 | Bridge | Maps DS tokens onto the consuming framework. | --primary: var(--ds-primary) |
Full dependency chain:
Figma variable → Base → Semantic → State → Component → Platform → Tailwind/shadcn
Why six layers
The system originally ran on four layers — Base → Semantic → Component → Tailwind. That structure worked until three things broke it.
| Problem | Symptom | Layer added |
|---|---|---|
| Hover and focus values were hard-coded into each component token | Changing the focus ring meant editing 14 component blocks | State |
| Dark mode was patched per-component | A new component shipped light-only and nobody noticed until QA | Platform |
| Framework variables were mixed into the semantic layer | The DS could not be read without knowing Tailwind | Bridge (formalized) |
The State and Platform layers do not introduce new values. They introduce rules about how values change — which is exactly the part that was previously duplicated by hand.
Layer rules
- A token may only reference the layer directly below it, or any layer further down. It may never reference the layer above.
- Components never reference Base tokens. If a component needs a raw value, the missing piece is a semantic token, not an exception.
- The State layer defines transformations, not new palettes. If a hover value is not derivable from its semantic parent, the semantic token is wrong.
- The Platform layer only ever remaps existing tokens. It never defines a token that exists nowhere else.
- The Bridge layer is the only layer allowed to know the name "Tailwind" or "shadcn". Everything below it is framework-agnostic.
Naming convention
--ds-{group}-{role}-{variant}-{state}
│ │ │ └── hover, active, selected, disabled
│ │ └── sm / md / lg / primary / secondary
│ └── bg, text, border, icon, shadow, radius
└── palette group, semantic role, or component name
Every token is prefixed --ds-. The prefix is what keeps the system from colliding with Tailwind's own variables when both are live in the same scope.
Figma variables → Base tokens
Figma variable collections map 1:1 onto the Base layer. The MCP server returns variable names with the collection path intact, so the mapping is mechanical:
| Figma variable | Base token |
|---|---|
Color/Accent/600 | --ds-accent-600 |
Spacing/4 | --ds-space-4 |
Radius/lg | --ds-radius-lg |
Type/Size/base | --ds-text-base |
Semantic and above are authored in code, not in Figma. Figma owns raw values; the DS owns meaning. This is why a renamed Figma style never breaks a component — it only ever invalidates one Base token.
Base tokens
Raw values with no attached meaning. These exist only to be referenced by higher layers.
Color
Seven palette groups, each spanning 10 steps from 900 (darkest) to 50 (lightest).
| Group | Range | Purpose |
|---|---|---|
--ds-primary-* | #111827 → #F8FAFC | Dark anchors — headings, text, structure |
--ds-accent-* | #001D6C → #F5F8FF | Brand blue — interactive elements |
--ds-info-* | #1E3A5F → #F0F7FF | Muted blue — informational, distinct from brand |
--ds-gray-* | #111827 → #F9FAFB | Text hierarchy, borders, surfaces |
--ds-green-* | #14532D → #F0FAF3 | Success |
--ds-yellow-* | #713F12 → #FDF8EA | Warning |
--ds-red-* | #7F1D1D → #FDF3F3 | Error, destructive |
All 70 steps, as declared. Every value the rest of this system derives — a hover one step darker, a border on the 400 step, an icon on the 700 — is resolvable from this table without opening ds.css.
| Group | 900 | 800 | 700 | 600 | 500 | 400 | 300 | 200 | 100 | 50 |
|---|---|---|---|---|---|---|---|---|---|---|
primary | #111827 | #1E293B | #334155 | #475569 | #64748B | #94A3B8 | #CBD5E1 | #E2E8F0 | #F1F5F9 | #F8FAFC |
accent | #001D6C | #002D9C | #0043CE | #0F62FE | #4589FF | #78A9FF | #A6C8FF | #D0E2FF | #E8F0FE | #F5F8FF |
info | #1E3A5F | #1E4D8C | #2563A0 | #3B82B8 | #60A5D4 | #93C5E8 | #BAD9F1 | #DBEAFE | #EFF6FF | #F0F7FF |
gray | #111827 | #1F2937 | #374151 | #4B5563 | #636971 | #727780 | #D1D5DB | #E5E7EB | #F3F4F6 | #F9FAFB |
green | #14532D | #166534 | #15803D | #22915A | #4AAD6E | #6EC98E | #A3DFBA | #C6EDCF | #E2F5E7 | #F0FAF3 |
yellow | #713F12 | #854D0E | #A16207 | #C07C14 | #D4952C | #E5B34A | #F0CE70 | #F5DEA0 | #FAF0D2 | #FDF8EA |
red | #7F1D1D | #922525 | #A93434 | #BB4A4A | #D46060 | #E08888 | #EBADAD | #F5D0D0 | #FAE8E8 | #FDF3F3 |
Plus --ds-white (#FFFFFF) and --ds-hero-gradient (linear-gradient(180deg, #0A4ABF, #0F62FE)), the one gradient in the system and a marketing-only surface.
Two gray steps are deliberately off-ramp from the usual Tailwind values: gray-500 (#636971, normally #6B7280) and gray-400 (#727780, normally #9CA3AF). Both were darkened until muted text and placeholder text clear 4.5:1 on white. See Color for the full contrast table.
Spacing
4px base unit, 10 steps. Every step is a multiple of 4 — there are no half-steps, and no arbitrary values.
| Token | rem | px | Typical use |
|---|---|---|---|
--ds-space-1 | 0.25rem | 4px | Badge dot-to-label |
--ds-space-2 | 0.5rem | 8px | Inline gaps, icon margins |
--ds-space-3 | 0.75rem | 12px | Transaction row internal padding |
--ds-space-4 | 1rem | 16px | Form field gaps, mobile edge |
--ds-space-5 | 1.5rem | 24px | Card padding, content padding |
--ds-space-6 | 2rem | 32px | Section spacing, desktop margin |
--ds-space-7 | 2.5rem | 40px | Large section dividers |
--ds-space-8 | 3rem | 48px | Major section breaks |
--ds-space-9 | 4rem | 64px | Page-level spacing |
--ds-space-10 | 6rem | 96px | Hero spacing |
Typography
Minor third scale (1.200), snapped to the 4px grid.
| Token | rem | px | Role |
|---|---|---|---|
--ds-text-2xs | 0.625rem | 10px | Dense metadata |
--ds-text-xs | 0.75rem | 12px | Captions, helper text |
--ds-text-sm | 0.875rem | 14px | Labels, secondary body |
--ds-text-base | 1rem | 16px | Body |
--ds-text-lg | 1.125rem | 18px | Lead paragraph |
--ds-text-xl | 1.375rem | 22px | H3 |
--ds-text-2xl | 1.75rem | 28px | H2 |
--ds-text-3xl | 2.25rem | 36px | H1 |
--ds-text-display | 2.5rem | 40px | Balance figures, hero numbers |
--ds-text-page-title | clamp(1.75rem, 1.25rem + 1.5vw, 2.063rem) | 28→33px | Fluid page title |
Families and weights:
| Token | Value |
|---|---|
--ds-font-sans | system-ui, -apple-system, "Segoe UI", sans-serif |
--ds-font-mono | ui-monospace, "SF Mono", "Cascadia Code", monospace |
--ds-font-regular | 400 |
--ds-font-medium | 500 |
--ds-font-semibold | 600 |
--ds-font-bold | 700 |
--ds-tracking-tight | -0.02em — Display and H1 only |
System fonts are a deliberate constraint, not a placeholder. Senco ships to users on metered and unreliable connections; a webfont is a render-blocking request that buys nothing the system needs. It also sidesteps the Figma MCP limitation that custom typefaces do not survive the pipeline.
Shape and elevation
| Token | Value | Applied to |
|---|---|---|
--ds-radius-none | 0 | Full-bleed sections |
--ds-radius-sm | 0.25rem (4px) | Small chips |
--ds-radius-md | 0.375rem (6px) | Buttons, inputs |
--ds-radius-lg | 0.5rem (8px) | Cards, dialogs |
--ds-radius-xl | 0.75rem (12px) | Modals, sheets, form cards |
--ds-radius-2xl | 1rem (16px) | Large cards, bottom sheets |
--ds-radius-full | 9999px | Pills, avatars |
--ds-border-width | 1px | Default border |
--ds-border-width-focus | 2px | Focus outline |
| Token | Value |
|---|---|
--ds-shadow-xs | 0 1px 2px rgba(0,0,0,0.03) |
--ds-shadow-sm | 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03) |
--ds-shadow-md | 0 4px 6px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.03) |
--ds-shadow-lg | 0 10px 15px rgba(0,0,0,0.06), 0 4px 6px rgba(0,0,0,0.03) |
--ds-shadow-xl | 0 20px 25px rgba(0,0,0,0.08), 0 8px 10px rgba(0,0,0,0.03) |
Shadow opacity tops out at 8%. Elevation in Senco signals layering, not drama — a modal should read as above the page, not as a different material.
Motion
| Token | Value | Used for |
|---|---|---|
--ds-duration-instant | 50ms | State feedback on press |
--ds-duration-fast | 100ms | Hover, focus |
--ds-duration-normal | 180ms | Default transition |
--ds-duration-slow | 300ms | Sheets, dialogs |
--ds-duration-slower | 500ms | Full-screen transitions |
--ds-ease-default | cubic-bezier(0.4, 0, 0.2, 1) | Everything, unless specified |
--ds-ease-in | cubic-bezier(0.4, 0, 1, 1) | Exits |
--ds-ease-out | cubic-bezier(0, 0, 0.2, 1) | Entrances |
--ds-ease-bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | Confirmation only |
--ds-transition | var(--ds-duration-normal) var(--ds-ease-default) | Composite shorthand |
Sizing
| Token | Value | Notes |
|---|---|---|
--ds-size-touch | 48px | Minimum touch target — WCAG 2.2 SC 2.5.8 |
--ds-size-icon | 20px | Default icon |
--ds-size-icon-sm | 16px | Inline with text-sm |
--ds-size-icon-lg | 24px | Standalone / nav icons |
--ds-size-avatar | 36px | Contact avatar |
--ds-size-appbar | 48px | Mobile app bar |
--ds-size-bottomnav | 52px | Bottom tab bar |
--ds-size-list-item | 60px | Transaction row |
--ds-input-height | 48px | Default control height |
--ds-input-height-sm | 36px | Dense control height |
Z-index
A fixed six-step scale. No component may invent a z-index outside it.
| Token | Value |
|---|---|
--ds-z-dropdown | 100 |
--ds-z-sticky | 200 |
--ds-z-overlay | 300 |
--ds-z-modal | 400 |
--ds-z-popover | 500 |
--ds-z-toast | 600 |
Toast sits above modal on purpose: a transfer failure must be readable while a confirmation dialog is still open.
| Swatch | Token | CSS var | Value |
|---|---|---|---|
| bg | --ds-bg | ||
| fg | --ds-fg | ||
| heading | --ds-heading | ||
| muted | --ds-muted | ||
| border | --ds-border | ||
| surface | --ds-surface | ||
| white | --ds-white | ||
| on-accent | --ds-on-accent | ||
| primary | --ds-primary | ||
| primary-hover | --ds-primary-hover | ||
| primary-light | --ds-primary-light | ||
| primary-900 | --ds-primary-900 | ||
| link | --ds-link | ||
| focus | --ds-focus | ||
| overlay | --ds-overlay | ||
| disabled-bg | --ds-disabled-bg | ||
| disabled-text | --ds-disabled-text | ||
| success-bg | --ds-success-bg | ||
| success-border | --ds-success-border | ||
| success-text | --ds-success-text | ||
| warning-bg | --ds-warning-bg | ||
| warning-border | --ds-warning-border | ||
| warning-text | --ds-warning-text | ||
| error-bg | --ds-error-bg | ||
| error-border | --ds-error-border | ||
| error-text | --ds-error-text | ||
| info-bg | --ds-info-bg | ||
| info-border | --ds-info-border | ||
| info-text | --ds-info-text |
| Token | CSS var | Value | Visual |
|---|---|---|---|
| space-1 | --ds-space-1 | ||
| space-2 | --ds-space-2 | ||
| space-3 | --ds-space-3 | ||
| space-4 | --ds-space-4 | ||
| space-5 | --ds-space-5 | ||
| space-6 | --ds-space-6 | ||
| space-7 | --ds-space-7 | ||
| space-8 | --ds-space-8 | ||
| space-9 | --ds-space-9 | ||
| space-10 | --ds-space-10 |
| Token | CSS var | Resolved value |
|---|---|---|
| font-sans | --ds-font-sans | |
| font-mono | --ds-font-mono |
Font sizes
| Token | CSS var | Value | Sample |
|---|---|---|---|
| radius-none | --ds-radius-none | ||
| radius-sm | --ds-radius-sm | ||
| radius-md | --ds-radius-md | ||
| radius-lg | --ds-radius-lg | ||
| radius-xl | --ds-radius-xl |
Semantic tokens
Intent-based aliases. Components reference this layer, not the Base layer. This indirection is what makes theme switching possible.
Color
| Token | Light value | Purpose |
|---|---|---|
--ds-bg | {white} | Page background |
--ds-surface | {gray-100} | Card / section fill |
--ds-fg | {gray-700} | Body text |
--ds-heading | {primary-900} | Headings, strong text |
--ds-muted | {gray-500} | Secondary text, hints, labels |
--ds-border | {gray-200} | Dividers, input borders |
--ds-primary | {accent-600} | Brand, primary actions |
--ds-primary-light | {accent-50} | Tinted backgrounds |
--ds-link | {accent-600} | Inline links |
--ds-focus | {accent-600} | Focus ring source color |
--ds-placeholder | {gray-400} | Placeholder text in fields |
--ds-overlay | color-mix(in srgb, {gray-900} 10%, transparent) | Modal and sheet backdrop |
On-colors
Text and icon colors used on filled surfaces. These exist so that no component has to guess whether white text is legible.
| Token | Value | Used on |
|---|---|---|
--ds-on-accent | #FFFFFF light, #0F172A dark | Labels and indicators on --ds-primary fills |
--ds-on-surface | {gray-900} | Light surfaces |
--ds-on-error | #FFFFFF | red-600 destructive fills |
Feedback
Each feedback state is a four-token set — background, border, text, icon — so a notice never has to compose its own palette.
| State | Background | Border | Text | Icon |
|---|---|---|---|---|
| Info | --ds-info-bg | --ds-info-border | --ds-info-text | --ds-info-icon |
| Success | --ds-success-bg | --ds-success-border | --ds-success-text | --ds-success-icon |
| Warning | --ds-warning-bg | --ds-warning-border | --ds-warning-text | --ds-warning-icon |
| Error | --ds-error-bg | --ds-error-border | --ds-error-text | --ds-error-icon |
| Tip | --ds-tip-bg | --ds-tip-border | --ds-tip-text | — |
Tip is accent-based rather than info-based. Info is a system message; a tip is Senco talking. Keeping them on different hues prevents the two from blurring together on a busy transfer screen.
Spacing and typography aliases
Semantic spacing names the relationship, not the size. These are conventions in use, not declared variables — the relationship is documented so it stays consistent, and formalizing it as tokens is open work:
| Convention | Resolves to | Meaning |
|---|---|---|
| Inline gap | --ds-space-2 | Between icon and its label |
| Field gap | --ds-space-4 | Between form fields |
| Section gap | --ds-space-6 | Between page sections |
| Card padding | --ds-space-5 | Inside a card |
State tokens
The State layer defines how a semantic token transforms under interaction. It holds no independent palette — every state value is derived from its semantic parent by a documented rule.
Transformation rules
| State | Rule | Example |
|---|---|---|
| Hover | One step darker on the base ramp (600 → 700) | --ds-primary → --ds-primary-hover |
| Hover (surface) | Elevate one shadow step | --ds-shadow-xs → --ds-shadow-sm |
| Focus, bordered control | Border → --ds-focus, plus a 3px halo at 20% opacity, no offset | --ds-focus-ring as box-shadow |
| Focus, borderless control | 3px solid --ds-focus outline, 2px offset | outline: var(--ds-focus-ring-width) solid var(--ds-focus) + outline-offset: var(--ds-focus-ring-offset) |
| Active | Same value as hover, no elevation change | Pressed state reads as "held", not "lifted" |
| Selected | Border → --ds-primary, background → --ds-primary-light | Radio card selection |
| Disabled | Background {gray-300}, text {gray-500} | --ds-disabled-bg / --ds-disabled-text |
Which focus form applies is decided by one question: does the control have a visible border at rest? Inputs, selects, input groups, and bordered cards use the bordered form. Buttons, links, toggle items, tabs, switches, list rows, and scroll regions use the outline. The indicator that must meet 3:1 is the border or the outline — the 20% halo is decoration and measures about 1.33:1 against white.
Because each rule is expressed once, the hover behavior of a button, a contact card, and a sidebar item are provably the same thing rather than three similar-looking implementations.
Implemented state tokens
| Token | Value |
|---|---|
--ds-primary-hover | var(--ds-accent-700) |
--ds-disabled-bg | var(--ds-gray-300) |
--ds-disabled-text | var(--ds-gray-500) |
--ds-focus-ring-color | var(--ds-focus) |
--ds-focus-ring-width | 3px |
--ds-focus-ring-opacity | 20% |
--ds-focus-ring-offset | 2px — used only by the outline form; a box-shadow has no offset |
--ds-focus-ring | 0 0 0 var(--ds-focus-ring-width) color-mix(in srgb, var(--ds-focus-ring-color) var(--ds-focus-ring-opacity), transparent) |
--ds-focus-ring-error | Same formula, sourced from --ds-red-600 |
The focus ring is built with color-mix() rather than a fixed rgba value. That is what lets dark mode remap --ds-focus and get a correct ring for free — the transformation survives the theme switch, because the transformation is the token.
State pairs in component tokens
Component tokens that carry state name it explicitly and in pairs:
--ds-actionbtn-primary-bg: var(--ds-primary);
--ds-actionbtn-primary-bg-hover: var(--ds-primary-hover);
--ds-actionbtn-primary-shadow: var(--ds-shadow-sm);
--ds-actionbtn-primary-shadow-hover: var(--ds-shadow-md);
A component token with a -hover variant but no base variant is a bug. Review catches this as an unpaired state.
Utility prefixes
In markup, states are applied with Tailwind's prefix syntax, which resolves through the Bridge layer:
| State | CSS prefix | Example |
|---|---|---|
| Hover | hover: | hover:bg-primary-hover |
| Focus | focus-visible: | focus-visible:ring-focus |
| Active | active: | active:bg-primary-hover |
| Disabled | aria-disabled: | aria-disabled:bg-disabled — controls here use aria-disabled, so disabled: never matches |
Focus uses focus-visible:, not focus:. A mouse click on a button should not leave a ring behind; a Tab key press must.
Component tokens
Scoped to a single component. Component tokens compose semantic and state tokens, and are the only place a component-specific decision is allowed to live.
Buttons — three components, three token sets
--ds-actionbtn-* does not style the ordinary button. It styles the dashboard action tile: a 72px-tall card with a 40px circular icon above its label. Reading it as the generic button is what produced a 6px-versus-9999px radius contradiction across three files, so the ownership is stated explicitly here.
| Component | Radius | Height | Token set |
|---|---|---|---|
| Button (default) | --ds-radius-md (6px) | --ds-size-touch (48px) | No component block — composes semantic + state |
| Pill button | --ds-radius-full | --ds-pillbtn-h (48px) | --ds-pillbtn-* |
| Action tile | --ds-actionbtn-radius (full) | --ds-actionbtn-min-h (72px) | --ds-actionbtn-* |
Action tile
| Token | Value |
|---|---|
--ds-actionbtn-primary-bg | var(--ds-primary) |
--ds-actionbtn-primary-bg-hover | var(--ds-primary-hover) |
--ds-actionbtn-primary-text | var(--ds-on-accent) |
--ds-actionbtn-primary-icon-bg | rgba(255,255,255,0.15) |
--ds-actionbtn-primary-shadow | var(--ds-shadow-sm) |
--ds-actionbtn-primary-shadow-hover | var(--ds-shadow-md) |
--ds-actionbtn-secondary-bg | var(--ds-bg) |
--ds-actionbtn-secondary-bg-hover | var(--ds-surface) |
--ds-actionbtn-secondary-text | var(--ds-heading) |
--ds-actionbtn-secondary-border | var(--ds-border) |
--ds-actionbtn-secondary-border-hover | var(--ds-accent-200) |
--ds-actionbtn-secondary-icon-bg | var(--ds-accent-50) |
--ds-actionbtn-secondary-icon-color | var(--ds-primary) |
--ds-actionbtn-secondary-shadow | var(--ds-shadow-xs) |
--ds-actionbtn-secondary-shadow-hover | var(--ds-shadow-sm) |
--ds-actionbtn-radius | var(--ds-radius-full) |
--ds-actionbtn-icon-size | 2.5rem |
--ds-actionbtn-icon-radius | var(--ds-radius-full) |
--ds-actionbtn-min-h | 4.5rem |
--ds-actionbtn-label-size | var(--ds-text-sm) |
--ds-actionbtn-label-weight | var(--ds-font-semibold) |
Pill button
| Token | Value |
|---|---|
--ds-pillbtn-h | 3rem |
--ds-pillbtn-px | 1.5rem |
--ds-pillbtn-font-size | var(--ds-text-sm) |
--ds-pillbtn-font-weight | var(--ds-font-semibold) |
--ds-pillbtn-primary-bg | var(--ds-primary) |
--ds-pillbtn-primary-text | var(--ds-on-accent) |
--ds-pillbtn-secondary-bg | var(--ds-bg) |
--ds-pillbtn-secondary-text | var(--ds-primary) |
--ds-pillbtn-secondary-border | var(--ds-primary) |
Button (default) has no component block. It composes --ds-primary / --ds-primary-hover / --ds-on-accent / --ds-border directly with --ds-radius-md. The danger variant has no component tokens either, and currently resolves to --ds-red-600 — a Base reference, which this layer's own rules forbid. Recorded in Known drift below.
Input
| Token | Value |
|---|---|
--ds-input-height | 48px |
--ds-radiocard-border | var(--ds-border) |
--ds-radiocard-border-hover | var(--ds-gray-400) |
--ds-radiocard-border-selected | var(--ds-primary) |
--ds-radiocard-bg-selected | var(--ds-primary-light) |
--ds-fileupload-dropzone-border | var(--ds-gray-300) |
--ds-fileupload-dropzone-border-active | var(--ds-primary) |
--ds-multiselect-chip-bg | var(--ds-gray-100) |
--ds-multiselect-chip-remove-hover | var(--ds-red-600) |
Notice / Feedback
Feedback components consume the semantic feedback sets directly — they define no component tokens of their own. This is intentional: a notice that needed its own colors would mean the semantic set was incomplete.
Senco components
Product-specific components carry the largest component-token blocks, because they encode domain decisions that no generic library would have:
| Component | Token group | Notable decision |
|---|---|---|
| Status badge | .snc-badge-* | Six statuses; the only place background, text, and dot come from three different ramps |
| Status timeline | --ds-timeline-* | Completed steps are green, active is brand blue, future is gray-300 |
| Bottom tab bar | --ds-bottomtab-* | Badge is red-600 on white — reads at a glance in sunlight |
| Camera capture | --ds-camera-* | Fixed #111827 chrome, not a theme token — the viewfinder is dark in both themes |
| QR scanner | --ds-qr-* | Same fixed-dark rule; scan line uses accent-400 for contrast on black |
| Receipt view | --ds-receipt-* | Paper metaphor with a tear edge; header and footer use gray-50 |
| Contact card | --ds-contactcard-* | Hover raises shadow, never changes border color |
Camera and QR surfaces are the one documented exception to theme remapping. A viewfinder inverts in light mode for no benefit and costs the user their frame of reference, so those tokens are hard values by design. The exception is recorded here rather than discovered in the CSS.
Platform tokens
The Platform layer resolves environment. It never invents values — it only remaps tokens that already exist, which is why adding a theme cannot break a component.
Theme resolution
Dark mode is a remap of the semantic layer, applied at .ds-layout.ds-dark:
| Token | Light | Dark |
|---|---|---|
--ds-bg | #FFFFFF | #0F172A |
--ds-surface | {gray-100} | #1E293B |
--ds-fg | {gray-700} | #CBD5E1 |
--ds-heading | {primary-900} | #F1F5F9 |
--ds-muted | {gray-500} | #94A3B8 |
--ds-border | {gray-200} | #334155 |
--ds-primary | #0F62FE | #458DFF |
--ds-primary-hover | #0043CE | #3079FF |
--ds-primary-light | {accent-50} | #0F2847 |
--ds-on-accent | #FFFFFF | #0F172A |
--ds-placeholder | {gray-400} | #8391A6 |
--ds-overlay | {gray-900} at 10% | {gray-900} at 30% |
Brand blue lightens in dark mode. #0F62FE on #0F172A measures 3.57:1 — under the 4.5:1 bar for text. #458DFF measures 5.53:1 and clears it. The base palette is untouched; only the semantic alias moves.
Lightening the primary fill has a consequence handled in this same layer: --ds-on-accent remaps to #0F172A, because a white label on #458DFF measures 3.23:1. The button component does not change — it still reads --ds-on-accent. See Themes.
Reduced motion
A single platform block neutralizes animation system-wide — components do not each implement their own guard:
@media (prefers-reduced-motion: reduce) {
.ds-layout *,
.ds-layout *::before,
.ds-layout *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Viewport resolution
Breakpoints are Platform-layer inputs, not component decisions. A component reads a container token; the Platform layer decides which container applies. See Responsive breakpoint tokens below.
Scoped platform targets
| Scope | Purpose |
|---|---|
.ds-layout | Documentation site — full DS token set |
.ds-layout.ds-dark | Dark theme remap |
.senco-prototype-scope | Device-frame prototypes — pins light values so the portfolio's own dark theme cannot leak into a mobile mockup |
The prototype scope is a real platform target, not a workaround. A prototype frame represents the Senco app, which has its own theme state independent of the page hosting it.
Density — defined, not implemented
The layer reserves compact and cozy density variants, remapping --ds-space-* and control heights. Neither is implemented in ds.css today. Senco's screens are single-task and mobile-first, so density has never earned its cost. The slot stays documented so that if a desktop transaction table ever needs it, it lands in the Platform layer rather than as per-component overrides.
Component library integration
The Bridge layer — the sixth and outermost. Senco builds on shadcn (base-nova style). A CSS variable bridge maps shadcn and Tailwind variables onto Senco semantic tokens inside the .ds-layout scope.
| shadcn variable | Maps to |
|---|---|
--background | --ds-bg |
--foreground | --ds-fg |
--card | --ds-white |
--card-foreground | --ds-heading |
--popover | --ds-white |
--primary | --ds-primary |
--primary-foreground | --ds-on-accent |
--secondary | --ds-white |
--secondary-foreground | --ds-primary |
--muted | --ds-surface |
--muted-foreground | --ds-muted |
--accent | --ds-surface |
--destructive | --ds-red-600 |
--border | --ds-border |
--input | --ds-border |
--ring | --ds-primary |
--radius | 0.5rem |
Sidebar and chart bridges follow the same pattern (--sidebar-*, --chart-1 … --chart-5).
This bridge lives in ds.css, so shadcn components pick up Senco branding without per-component overrides.
The bridge depends on inheritance
The bridge is declared once, on the scope root, and every component reads it by inheritance. Anything that re-declares the same framework variables on descendants — a host application's theme written as .theme * rather than .theme — silently replaces the Senco values with the host's, and nothing looks broken except the colours. When the system is embedded in a host with such a rule, the scope re-asserts inheritance for exactly the variables the bridge defines:
.ds-scope.host-theme *:not(.prototype-scope) {
--primary: inherit;
--primary-foreground: inherit;
/* …every variable the bridge declares */
}
Verification is a single check: a primary button inside the scope must render --ds-primary in light mode and the dark value after the theme switches. If it renders the same colour in both themes, the bridge is not reaching it.
Why the bridge is a layer, not a detail
Everything below layer 6 is technology-agnostic. Moving Senco from Tailwind to CSS-in-JS, or to a native platform, means rewriting one file — the bridge — and nothing else. The DS does not know what renders it, and that is the property the layer exists to protect.
Timing and threshold values
Durations that are not animation. A spec that says a value is "debounced", "throttled", or "has a timeout" has described a requirement and withheld the only part a developer cannot derive — and two developers will pick different numbers, which for a rate quote means two different prices on screen.
These are system defaults. A product surface may override one, but it may not leave it unstated.
| Constant | Value | Applies to | Why this number |
|---|---|---|---|
| Input settle | 300ms | Currency conversion recalculation, search filtering | Below ~200ms it fires mid-word; above ~500ms the result feels detached from the typing. 300ms is the interval at which a pause reads as "finished typing" |
| Announcement throttle, routine | 30s | Rate refresh announcements | Matches the dashboard rate poll, so the announcement and the visual update are the same event rather than two |
| Announcement throttle, progress | 10% or 1s, whichever is less frequent | Upload and verification progress | Announcing every percent is unusable; announcing only at the end is uninformative |
| Announcement debounce, validation | 500ms | Password strength, async field checks | Long enough that a fast typist is not interrupted per character |
| Quote validity, fiat | 60s | FX rate held between quote and confirmation | Long enough to read a fee breakdown without rushing, short enough that the rate is still real |
| Quote validity, crypto | 15s | Stablecoin conversion rate | Crypto pairs move inside a fiat window; holding one for 60s means quoting a price the desk cannot honour |
| Request timeout, default | 10s | Any request whose spinner a user is watching | Past 10s on a slow connection a user has already decided the app is broken; the failure state is more useful than more waiting |
| Request timeout, upload | 30s | KYC document upload | Sized for the payload, not the attention span — this is the longest legitimate operation in the product |
| Submit re-enable | When the caller sets loading back to false — on response or on timeout | Every submitting control | The caller owns the request, so the caller owns the release. The caller sets loading to true synchronously in the press handler, before any await, which is what stops a second tap in the same frame |
Rate expiry is visible before it happens
A quote does not expire silently at zero. The remaining validity is shown from the halfway mark, and expiry replaces the figure with a refresh action rather than a stale number. See Amount input and Transfer flow.
Numeric display precision
| Value | Precision | Example |
|---|---|---|
| Fiat amount | The currency's minor unit | 250.00 USD, 412,500 NGN, 3,200 JPY |
| Crypto amount | 8 decimal places, trailing zeros trimmed | 0.0042 BTC |
| FX rate | Exactly 6 significant figures — minimumSignificantDigits: 6, maximumSignificantDigits: 6 | 1 USD = 1,650.00 NGN, 1 USD = 0.923145 EUR |
| Fee | The currency's minor unit, never rounded up | 3.50 USD |
"Full precision" previously stood in for the FX rate row. Six significant figures is what makes a rate comparable across corridors — a fixed decimal count either truncates 0.923145 or pads 1,650.00 with noise.
Currency minor units
The minor unit drives amount precision, and three examples are not a contract. The system takes minor units from ISO 4217, via Intl.NumberFormat, with two exceptions it must carry itself:
| Case | Handling |
|---|---|
| ISO 4217 currencies | Intl.NumberFormat(locale, { style: "currency", currency }).resolvedOptions() |
| Crypto assets | Not in ISO 4217 — carried in a product-owned table; 8 decimals for BTC, 6 for USDC/USDT |
| Display rounding | Never rounds a fee up or a received amount up |
Known gaps — not yet tokenized
None of the values above exist as --ds-* declarations in ds.css. They are a contract stated here and implemented in component logic. Promoting them to tokens (--ds-timing-settle, --ds-timing-quote-fiat) is open work; until then this table is the single source and a component that picks its own number is a defect.
Responsive breakpoint tokens
| Token | Value | Applies to |
|---|---|---|
--ds-bp-mobile | 600px | Mobile portrait and below |
--ds-bp-tablet | 1024px | Tablet portrait to landscape |
--ds-bp-desktop | 1440px | Standard desktop and above |
Container widths resolved at each breakpoint:
| Token | Value |
|---|---|
--ds-container-xs | 320px |
--ds-container-sm | 480px |
--ds-container-md | 640px |
--ds-container-lg | 960px |
--ds-container-xl | 1200px |
--ds-container-max | 1400px |
Rules
- Never hard-code raw values in component styles.
- Always reference the layer directly below. Never skip upward.
- Base tokens exist only to be referenced by higher layers.
- Components never reference Base tokens. A missing semantic token is the real problem.
- State tokens must be derivable from their semantic parent by a documented transformation.
- Every component token carrying
-hovermust have an unprefixed base counterpart. - Platform overrides only remap. A token introduced inside a theme block is a bug.
- All interactive elements define hover, focus-visible, and disabled states.
- shadcn components inherit Senco tokens via the bridge — do not override individually.
Adding a token
- Does an existing semantic token cover it? If yes, stop.
- If the value is raw and reusable, add it to Base and alias it in Semantic.
- If it only varies by interaction, it belongs in State as a transformation.
- If it applies to exactly one component, add it to that component's block — and only after 1–3 have failed.
- Record the decision. A token whose reason is not written down gets duplicated within a quarter.
Deprecating a token
Removal is a two-step change, never one. Mark the token deprecated in ds.css with its replacement named inline, leave it aliased for one release, then delete. An immediate delete turns into a hunt across 29 spec files and every prototype.
Known drift
| Item | Status |
|---|---|
--ds-focus-offset | Removed — superseded by --ds-focus-ring-offset |
| Dark theme overrides three Base tokens | .ds-layout.ds-dark redefines --ds-accent-50, -100, and -200, which the rules above forbid. They are the tints behind --ds-primary-light and the sidebar's active row; in dark mode those need a dark tint, and remapping the palette step was the shortest path. The correct fix is a semantic tint token that each theme points somewhere different, leaving the ramp alone. Until then, "one brand blue means one thing everywhere" is true of accent-600 and not of the low end of the ramp. --ds-white is also remapped and that one is intended — it is a semantic token wearing a colour's name |
| Neutral feedback set | No --ds-gray-bg / -text / -border / -icon exists. The Cancelled status reaches past the semantic layer to --ds-gray-100 / -600 / -500 directly — the one sanctioned Base reference in a component, pending a neutral set |
--ds-tip-icon | The Tip feedback set has bg, border, and text but no icon token; Tip currently ships without one |
| Danger button tokens | No --ds-actionbtn-danger-* or equivalent block exists. The danger variant resolves to --ds-red-600, a Base reference this layer forbids |
| Link hover / destructive | --ds-link has no -hover pair and reuses --ds-primary-hover. .link-destructive is a class with no --ds-link-destructive token |
--ds-space-0 | Used as a value, not declared as a variable |
| Line-height tokens | Ratios are specified per role in Typography; no --ds-leading-* tokens exist |
| Status badge compact spacing | 3px / 10px padding and a 6px gap sit off the 4px scale; kept because they fit the 60px transaction row |
| Density variants | Documented above, not implemented |
| Semantic spacing aliases | Documented above; several are conventions in use rather than declared variables in ds.css |
Drift is listed rather than hidden. An agent reading this file needs to know which statements are contracts and which are intent — an undocumented gap is what produces confidently wrong generated code.