Skip to content

Design System

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

Design system

This page is the reference for openrunic's visual language: the palette with its real token names, the type scale, the rules that govern status colour, and the accessibility constraints. It is for anyone writing CSS in this repository.

The implementation is @openrunic/ui. Every token below exists as a CSS custom property; nothing here is aspirational styling.

The idea

The interface is warm paper. Depth is expressed by stepping through three paper tones before any shadow is considered, and colour is used sparingly enough that when something is coloured, it means something.

The working ratio is roughly 60 percent bone, 25 percent espresso, 10 percent caramel and hazelnut, and 5 percent terracotta. If a screen feels busy, remove caramel first.

Palette

Base

Token Value Role
--bone #F5EFE6 Primary background, application and marketing
--cream #EDE4D3 Raised surfaces, cards, alternating sections
--white #FFFFFF Input fields and data tables only
--espresso #2E211A Primary ink: text, logo, navigation, footer. Never pure black
--espresso-2 #3E2C23 Secondary ink, dark surface hover
--hazelnut #8C5E3C Secondary text on bone, icons, strong borders
--caramel #A9714B Decorative only: large headings, rules, illustration
--terracotta #C4552D Accent: calls to action, links, active states, focus ring
--terracotta-deep #A8431F Accent hover and pressed; small text links on bone
--olive #6B7A4F Success and in-range. A reserved semantic colour
--line #E2D7C3 Hairline borders and dividers on bone
--danger #B3362B Warm-shifted red. Use sparingly

Semantic layers

Components read these, not the base tokens.

Surfaces  --bg-page  --surface-card  --surface-field  --surface-data
          --surface-inverse  --surface-inverse-hover  --surface-wash

Ink       --text-ink  --text-heading  --text-secondary  --text-decorative
          --text-inverse  --text-inverse-secondary  --text-link  --text-link-hover

Action    --action-primary  --action-primary-hover  --action-primary-ink
          --action-accent   --focus-ring

Borders   --border-hairline  --border-strong  --border-inverse

Status    --status-success  --status-success-wash
          --status-neutral  --status-neutral-wash
          --status-danger   --status-danger-wash

Data viz  --viz-1 .. --viz-6        categorical, warm-shifted
          --viz-seq-0 .. --viz-seq-5  sequential, bone to espresso

One naming note. The design source calls body ink --text-body. The library renames it --text-ink, because typography.css is imported after colors.css and owns --text-body as a length. A colour of the same name would lose the cascade and resolve to 16px, which is invalid for a colour property. The rename is documented in the token file.

Type scale

Three families, all self-hosted and never loaded from a CDN, which is a stated privacy decision:

--font-display    Bricolage Grotesque, then system sans
--font-text       Bricolage Grotesque, then system sans
--font-editorial  Fraunces, then Georgia
--font-mono       Spline Sans Mono, then system mono

All are variable fonts. The display family carries a weight, optical-size, and width axis; the editorial family adds softness and wonk axes; the mono family carries weight.

Sizes

Token Value
--text-hero clamp(44px, 6vw, 72px)
--text-h1 40px
--text-h2 30px
--text-h3 22px
--text-body-lg 18px
--text-body 16px
--text-small 14px
--text-overline 13px
--text-caption 12.5px

Line heights --lh-hero: 1.05, --lh-h1: 1.1, --lh-h2: 1.15, --lh-h3: 1.25, --lh-body: 1.6, --lh-small: 1.5, --lh-caption: 1.4.

Weights --wght-regular: 400, --wght-medium: 500, --wght-semibold: 600. There is no bold weight in the scale.

Tracking --track-tight: -0.02em, --track-normal: 0, --track-caption: 0.01em, --track-overline: 0.14em.

Optical size is set per role rather than left to the browser: --opsz-hero: 96, --opsz-heading: 48, --opsz-h3: 24, --opsz-ui: 12.

Role classes. Use these rather than restyling elements: .or-hero, .or-h1, .or-h2, .or-h3, .or-body-lg, .or-body, .or-small, .or-caption, .or-overline, .or-editorial, .or-mono. The mono role sets tabular figures, which matters for money columns and lab values.

Casing. Sentence case everywhere. The overline is the only permitted all-caps.

Spacing, radius, elevation, motion

Spacing is a 4 px scale: --space-1 at 4 px through --space-10 at 128 px, with 24, 32, 48, 64, and 96 in between. Layout tokens: --content-max: 1120px, a 12-column grid with a 24 px gutter, --section-y and --hero-y at 96 px, --card-pad at 24 px, control heights of 32, 40, and 48 px, and a 1 px hairline.

Radius is four values: --radius-sm: 6px for tags and chips, --radius-md: 10px for cards and fields, --radius-lg: 16px for modals, and --radius-pill: 999px. --radius-button aliases the pill, which makes button shape a single global switch.

Elevation is deliberately shallow. Depth comes from stepping bone to cream to white before any shadow. There are exactly two shadows, both espresso-tinted rather than grey-black, plus a scrim:

--shadow-raised:  0 1px 2px rgba(46,33,26,.06), 0 2px 8px rgba(46,33,26,.05);
--shadow-overlay: 0 4px 12px rgba(46,33,26,.10), 0 12px 40px rgba(46,33,26,.12);
--scrim-espresso: rgba(46,33,26,.82);

At most one shadow layer per screen.

Motion is one easing curve and three durations: --ease-out: cubic-bezier(.2,.7,.2,1), with 150, 200, and 250 ms. The doctrine is opacity plus a translate of at most 6 px. No spring, no bounce, no parallax, no scroll-jacking. Reduced motion is honoured globally by collapsing durations to 0.01 ms.

One signature animation exists: the brand glyph draws its six strokes 60 ms apart, used as a loading affordance.

Status colour discipline

This is the part most likely to be got wrong, so the rules are absolute.

Three status tiers, and they are reserved.

Meaning Colour Wash
Success, in range --olive #6B7A4F #E7E9DC
Neutral, informational --hazelnut #8C5E3C #EFE3D5
Danger, out of range, destructive --danger #B3362B #F6DFD9

Olive is a semantic colour, not a palette colour. Do not decorate with it.

The brand accent is not a status colour. Terracotta is the only saturated colour in the system and is scoped to calls to action, links, focus rings, and active navigation. A destructive action uses the danger variant, never terracotta. The button component says so in its own prop documentation.

Never colour alone. Every status carries a label or an icon. The token file itself carries this as a comment above the status block. For numbers the rule is stronger: a value is always paired with a unit and a worded range state, for example 7.4 mmol/L, above range. Never a bare number, never colour as the only signal.

This is enforced structurally in the library rather than left to discipline. Badge attaches a per-tone icon automatically. VitalStat drops its whole state row when no state label is supplied, rather than rendering a coloured chip with no words.

Data visualisation desaturates and warm-shifts into the family. Categorical series use --viz-1 through --viz-6; sequential ramps run bone to caramel to espresso through --viz-seq-0 to --viz-seq-5. Never a charting library's default palette.

The web app needed a fourth, caution tier and defined one locally with color-mix over caramel, flagged in a comment as a proposed library addition rather than quietly forked. That is the correct way to handle a gap: use it locally, name it as a gap.

Accessibility rules

Contrast, verified. These ratios are measured and recorded, not estimated:

Pair Ratio
espresso on bone 13.62
espresso on cream 12.34
bone on espresso 13.62
terracotta-deep on bone 5.27
hazelnut on bone 4.86
white on terracotta-deep 6.02
white on terracotta 4.48
bone on olive 4.06
terracotta on bone 3.92

Three rules follow directly:

  • Hazelnut is the only secondary text colour that passes AA for body text.
  • Caramel is decorative only. It measures 3.57 on bone and fails AA for body text. Headings at 24 px and above, rules, and illustration only.
  • Small text links must use terracotta-deep, not terracotta. Terracotta at 3.92 qualifies for large text and interface components on bone; small text needs the deep variant at 5.27.

Focus is a 2 px terracotta ring at 2 px offset on every focusable element, always through :focus-visible so keyboard users get it and mouse users do not.

Interaction states. Hover always darkens, never lightens. Press darkens further. Nothing scales or bounces. Disabled is 0.42 opacity with no colour change, so the control keeps its identity.

Touch targets are at least 44 px in each dimension below medium widths, implemented across every interactive component in the library.

Reduced motion is honoured globally.

Hard constraints

These are non-negotiable and rejecting a pull request for them is expected:

  • No gradients anywhere.
  • No frosted glass or backdrop blur.
  • No noise, grain, mesh, or aurora effects.
  • No emoji or decorative unicode. Icons come from the Icon component.
  • No runic, Norse, or Viking motifs, despite the name.
  • No red cross, green cross, caduceus, or Rod of Asclepius.
  • The logo never sits on photography without a solid panel or a scrim of at least 80 percent.
  • The wordmark ships as outlines. Do not retype it.

Related pages

Clone this wiki locally