Bruhs home Agents

Agents

Give this guide to your coding agent to use Bruhs in a project. It covers setup and the design rules.

Read plain text

Bruhs design system guide

Use Bruhs to build interfaces with warm dark surfaces and Monaspace type. Colors use OKLCH and names drawn from fruit. Background tones and inset rings show depth.

Bruhs works anywhere Tailwind CSS v4 runs. A compiled CSS bundle also works in plain HTML.

Use the tokens and classes in this guide. Keep custom values within the documented scales.


1. Adoption

Option A: Tailwind v4 (preferred)

/* your-app.css */
@import "tailwindcss";
@import "@bruhs/theme/styles/index.css";

Serve fonts at /fonts/*. Copy @bruhs/theme/fonts/* into public/fonts/ at build time, or link them through your bundler. The CSS uses relative font paths.

The import sets the page colors and Monaspace Neon as the default font. It also sets the font-mono family and persimmon focus ring. See section 12 for document defaults.

Keep it in the vocabulary

Add bruhs-theme-lint to your check script. It checks the rules in section 14 and rejects raw values that bypass the tokens.

"check": "tsc && bruhs-theme-lint src"

Use --skip type,weight when a project deliberately uses different type rules. Mark a local exception with a bruhs-theme-ignore comment on that line or the line above. Explain why it is needed.

Option B: Compiled CSS

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@bruhs/theme/dist/bruhs.css">

Add this stylesheet to any HTML document. The CDN serves the compiled utilities and fonts.

Option C: Host the files

Download dist/bruhs.css and the fonts/ directory from the package. Serve both from your own host.


2. Core philosophy

Set all text in Monaspace. Each text role uses a specific family.

Use longan for the warm dark canvas. Keep gray and pure black out of the neutral palette.

Show depth with background tones and inset-ring. Bruhs omits shadow-xs through shadow-2xl.

Reserve persimmon for primary actions and focus. Use it sparingly to draw attention.


3. Color vocabulary

The palette has seven accent scales and two neutral scales, plus mascot colors. Each scale has 11 OKLCH shades from 50 to 950. Use token names for colors.

Token Role Tint
lychee Errors, destructive actions, critical alerts Red
persimmon Primary actions, brand, focus, bloom Orange
durian Warnings, attention highlights Yellow
pandan Success, live indicators, positive states Green
blueberry Informational, links, selection Blue
mangosteen Decorative, secondary accents Purple
dragonfruit Decorative, tertiary accents Pink
rambutan Foreground text, UI surfaces Warm gray
longan Background canvas, panels, cards Warm dark brown
bruh-{base,eye,mouth} Mascot branding only Custom

Utilities

Use these names with any Tailwind color utility.

Purpose Examples
Background and text bg-persimmon-500, text-rambutan-100
Borders and focus border-pandan-400, ring-persimmon-500, outline-persimmon-400
Inset rings inset-ring-white/10
Icons fill-lychee-400, stroke-durian-300
Decoration and accents decoration-blueberry-500, divide-rambutan-100/10, accent-persimmon-500

Use opacity for softer tints, as in bg-persimmon-500/15. Keep the existing shades.

Canvas / panel / card tones

Surface Class Background Ring
Page canvas Theme default on <body> bg-longan-950 None
Panel / sidebar / nav surface-panel bg-longan-900 inset-ring inset-ring-white/5
Card surface-card bg-longan-800 inset-ring inset-ring-white/10
Floating (popover, menu) surface-floating bg-longan-700 inset-ring inset-ring-white/15
Modal surface-modal bg-longan-800 on bg-longan-950/70 scrim inset-ring inset-ring-white/20
Well (recessed) surface-well bg-longan-900/60 inset-ring inset-ring-white/5

4. Radius vocabulary

Use the fruit radius classes below. Tailwind's default radius classes, from rounded-sm through rounded-full, aren't part of Bruhs.

Class Value Use for
rounded-seed 4px Tags and compact controls
rounded-grape 8px Buttons, inputs, badges
rounded-lychee 14px Cards, panels, sidebar items
rounded-mango 22px Hero tiles, media containers
rounded-pomelo 36px Marketing blocks and pull quotes
rounded-orb full Avatars, pills, toggles, dots

Use directional variants for individual corners or edges, such as rounded-l-grape.

Concentric nesting

Subtract the padding from the outer radius to get the inner radius. Use CSS variables to keep nested curves parallel.

<div class="rounded-mango p-4 [--radius:var(--radius-mango)] [--padding:--spacing(4)]">
  <div class="rounded-[calc(var(--radius)-var(--padding))] p-4">inner</div>
</div>

5. Typography

Each text role class sets its Monaspace family. Keep that family by omitting font-* overrides on .text-* roles.

Class Font Size Weight Use for
text-display-lg Krypton clamp(40…72px) 300 Hero headlines
text-display-md Krypton clamp(32…56px) 400 Section heroes
text-display-sm Krypton clamp(28…44px) 400 Page headers
text-h1 Xenon clamp(24…32px) 500 Primary headings
text-h2 Xenon 24px 500 Section headings
text-h3 Xenon 20px 500 Subsections
text-h4 Xenon 18px 500 Component headings
text-body-lg Neon 18px 400 Lead paragraphs
text-body Neon 16px 400 Default prose
text-body-sm Neon 14px 400 Dense / secondary prose
text-label-lg Argon 16px 500 Form labels
text-label Argon 14px 500 UI labels, metadata
text-label-sm Argon 12px 500 Captions, annotations
text-eyebrow Argon 11px, uppercase 500 Label above a heading or group
text-code-lg Neon 16px 400 Code blocks
text-code Neon 14px 400 Code snippets
text-code-sm Neon 12px 400 Small code annotations
text-button-lg Argon 16px 500 Primary action buttons
text-button Argon 14px 500 Standard buttons
text-button-sm Argon 12px 500 Compact buttons, chips
text-callout Radon 18px 400 Pull quotes, asides

Rules

Use font-semibold or font-medium for emphasis. Bruhs omits font-bold.

Keep the heading role's line height. Add text-balance to headings and text-pretty to body paragraphs.

Limit prose width with max-w-[65ch] or max-w-[72ch]. Use tabular-nums for changing numbers.

Let inline elements inherit their parent's text role. A color class such as text-rambutan-200 is fine on inline code. Reserve text-code-* roles for block code.


6. Elevation

Use background tones and inset rings to show depth.

Step Class Background Ring
Canvas Theme default bg-longan-950 None
Panel surface-panel bg-longan-900 inset-ring-white/5
Card surface-card bg-longan-800 inset-ring-white/10
Floating surface-floating bg-longan-700 inset-ring-white/15
Modal surface-modal bg-longan-800 inset-ring-white/20

Each surface-* class sets the tone and ring with rounded-lychee corners. Override the radius with a fruit radius class. Add surface-interactive to strengthen a clickable surface's ring on hover.

Stacking layers

Prefer native <dialog> and [popover] elements. They use the browser's top layer. For other overlays, use a named stacking layer.

Class Value Use for
z-raised 10 Lifted above siblings
z-sticky 20 Sticky headers and toolbars
z-overlay 40 Backdrops behind overlays
z-modal 50 Custom drawers and modals
z-popover 60 Menus and command palettes (above a modal)
z-toast 70 Toasts
z-tooltip 80 Tooltips

The values set the stacking order. Variables from --z-raised through --z-tooltip are available on :root for plain CSS.

Bloom

Use --shadow-bloom for a persimmon glow on focus or active controls. Reserve it for deliberate attention.

<button class="[box-shadow:var(--shadow-bloom)]">Focused</button>

Keep glows in persimmon.


7. Motion

Use the motion tokens to keep transitions consistent. Five durations and four easing curves are available as CSS variables and Tailwind utilities.

Durations

Token Value Use for
duration-blink 80ms Brief selection feedback
duration-fast 150ms Hover, button press, small state changes (default)
duration-standard 250ms Expand, fade, slide
duration-slow 400ms Page transitions and large panels
duration-bloom 600ms Deliberate attention moments, bloom accents

The default transition uses duration-fast at 150ms.

Easings

Token Curve Use for
ease-standard cubic-bezier(0.2, 0, 0, 1) Default for most UI transitions
ease-entrance cubic-bezier(0, 0, 0, 1) Content entering view
ease-exit cubic-bezier(0.23, 1, 0.32, 1) Content leaving view
ease-bloom cubic-bezier(0.34, 1.56, 0.64, 1) Playful overshoot for brand accents

Rules

Use transition for the default duration. Name the changing property when possible, as in transition-colors.

Use an ease-out curve for anything entering or leaving view, ease-entrance or ease-exit. An ease-in curve starts slowly, which reads as lag at the moment someone is watching. Keep routine UI motion under 300ms; duration-slow and duration-bloom are for page transitions and brand moments.

Pair each duration with a suitable easing. Use duration-standard ease-standard for expanding content. Reserve duration-bloom ease-bloom for brand accents.

Respect prefers-reduced-motion. Wrap decorative motion in motion-safe: variants.

Keep routine transitions at 400ms or less. Durations over 1000ms are reserved for splash screens or onboarding.


8. Spacing

Use Tailwind's spacing scale, based on --spacing: 0.25rem. Choose the common values below for a consistent rhythm.

Common patterns

Context Value Why
Icon to text gap-1.5 (6px) Tight without touching
Form label to field gap-2 (8px) Closely related content
Form field to field gap-6 (24px) Clear separation
Card padding (compact) p-4 (16px) Dense apps
Card padding (default) p-6 (24px) Standard
Section to section gap-12 md:gap-16 (48/64px) Room between sections
Page padding p-6 md:p-8 (24/32px) More space on larger screens

Rules

Use gap-* on the parent to space flex or grid children. Keep margin utilities out of that spacing.

Use shorthand such as p-4. Split axes when a variant overrides one, as in p-6 md:px-8.

Use --spacing(…) for custom spacing values. It replaces raw pixels, calc(var(--spacing)*…) and theme(spacing.…).


9. Responsive breakpoints

Start with the mobile layout. Use Tailwind's default breakpoints to adapt it for larger screens.

Breakpoint Minimum width
sm: 640px
md: 768px
lg: 1024px
xl: 1280px
2xl: 1536px

Layout shifts

Shift At breakpoint
One to two columns md: (768px)
Two to three columns lg: (1024px)
Three to four columns xl: (1280px)
Hidden navigation to visible sidebar lg: (1024px)
Page padding from 24px to 32px md: (768px)
Section gap from 48px to 64px md: (768px)

Typography

Display roles and text-h1 use clamp() to scale with the viewport. Other heading roles use fixed sizes. Keep the size set by each role instead of adding md:text-* overrides.

Body text uses a fixed size. If a dense layout uses text-body-sm, enlarge it on mobile with max-sm:text-base/6.

Rules

Use min-h-dvh for full viewport height.

Keep touch targets at least 44px square at every breakpoint. Add an invisible target around small icon buttons.

Adjust dividers when the column count changes. See section 10.18.

Make choice controls larger on mobile with size-5 sm:size-4 or w-11 sm:w-9.


10. Component patterns

Use the component classes in @bruhs/theme. They live in src/styles/components.css and share the design rules across apps.

Utilities on the same element override component defaults. Use btn btn-primary w-full for a full width button or surface-card rounded-mango for a different radius.

10.1 Buttons

Use btn with a tone class. Add btn-sm for a smaller button. Use btn-icon for a square button that shows only an icon.

<button type="button" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-soft">Cancel</button>
<button type="button" class="btn btn-outline">Details</button>
<button type="button" class="btn btn-ghost">Skip</button>
<button type="button" class="btn btn-soft-danger">Remove</button>
<button type="button" class="btn btn-danger">Delete account</button>
<button type="button" class="btn btn-soft btn-sm">Resend code</button>

Use one btn-primary per page or dialog. Match its ring to its fill.

Use btn-soft-danger for destructive actions. Choose btn-danger when deletion is the main action.

The default button is about 36px tall. btn-sm is about 28px. Use at most two sizes per surface.

Set disabled on a button to prevent activation and show disabled styles. With aria-disabled="true", the styles apply but your code must prevent activation. This also applies to links with class="btn".

Match the padding beside an icon to the vertical padding. Use pl-2 for a leading icon or pr-2 for a trailing icon.

Give icon buttons an aria-label. On touch screens, btn-icon adds an invisible 48px touch target.

<button type="button" class="btn btn-primary pl-2">
  <svg viewBox="0 0 16 16" fill="currentColor" class="size-4 shrink-0"><!-- icon --></svg>
  New item
</button>
<button type="button" class="btn btn-ghost btn-icon" aria-label="Close">
  <svg viewBox="0 0 16 16" fill="currentColor" class="size-4"><!-- icon --></svg>
</button>

10.2 Text input and textarea

<div class="flex flex-col gap-2">
  <label for="email" class="text-label text-rambutan-200">Email</label>
  <input id="email" name="email" type="email" placeholder="you@bruhs.dev" class="input" />
</div>
<textarea id="bio" name="bio" rows="3" class="input"></textarea>

Set aria-invalid="true" for the lychee error state. Point aria-describedby to the error message. The focus ring stays inside the field.

10.3 Surfaces

Choose a surface class from section 6 to set its background and ring.

<div class="surface-card p-6">
  <h3 class="text-h3 text-rambutan-100 mb-2">Card title</h3>
  <p class="text-body text-rambutan-300">Card body.</p>
</div>
<a href="/settings" class="surface-panel surface-interactive block p-5">Settings</a>

10.4 Dialog

Add dialog to a native <dialog> and open it with showModal(). The class sets the modal surface and rounded-mango corners. It adds a longan backdrop and a 250ms entrance animation. Reduced motion skips the animation.

<dialog id="trash" class="dialog" aria-labelledby="trash-title">
  <form method="dialog" class="flex flex-col gap-4">
    <h2 id="trash-title" class="text-h3">Move to Trash?</h2>
    <p class="text-body-sm text-rambutan-400">You can restore the file until you empty the Trash.</p>
    <div class="flex justify-end gap-3">
      <button type="submit" value="cancel" class="btn btn-ghost">Cancel</button>
      <button type="submit" value="confirm" class="btn btn-danger">Move to Trash</button>
    </div>
  </form>
</dialog>

If clicking the backdrop should close the dialog, put padding on an inner wrapper and use dialog p-0. A click on the dialog's own padding counts as an inside click.

10.5 Menu, listbox and command palette

Use popover for a floating menu surface and menu-item for each row. The surface works on a [popover] element or a positioned div. A positioned div uses z-popover.

Rows highlight on hover or when marked with an active state. The class reads aria-selected, aria-pressed, aria-current and data-highlighted.

<div class="popover w-64" role="menu" aria-label="File">
  <button type="button" class="menu-item" role="menuitem">Rename</button>
  <button type="button" class="menu-item" role="menuitem" data-highlighted>Duplicate</button>
  <button type="button" class="menu-item" role="menuitem" aria-disabled="true">Share</button>
</div>

10.6 Tooltip

Use tooltip for the bubble. Show it on hover and keyboard focus. Give the trigger an aria-label or connect it to the tooltip with aria-describedby.

<button type="button" class="group relative btn btn-ghost btn-icon" aria-describedby="tip">
  <svg viewBox="0 0 16 16" fill="currentColor" class="size-4" aria-hidden="true"><!-- icon --></svg>
  <span id="tip" role="tooltip" class="tooltip absolute bottom-full left-1/2 mb-2 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">Copy link</span>
</button>

10.7 Segmented control

Use a segmented control to choose one of two to four views. Mark the active item with the ARIA state that matches its role. The class reads aria-pressed, aria-selected and aria-checked.

<div class="segmented" role="group" aria-label="View">
  <button type="button" class="segmented-item" aria-pressed="true">Preview</button>
  <button type="button" class="segmented-item" aria-pressed="false">Code</button>
</div>

10.8 Code and keys

<pre class="code-block"><code>pnpm add @bruhs/theme</code></pre>
<p class="text-body">Run <code class="code-inline">pnpm check</code> first.</p>
<kbd class="kbd">⌘</kbd><kbd class="kbd">K</kbd>

Use code-block for horizontal scrolling instead of wrapping. Remove the syntax highlighter's background so the block's background shows.

10.9 Spinner

<span class="spinner size-4 text-rambutan-400" aria-hidden="true"></span>

Set the spinner's size with size-* or the font size. Set its color with a text color class. Use --spinner-width to change the default 2px stroke.

Pair it with visible text or label its button. The spinner slows down for reduced motion.

10.10 Empty, error and 404 states

<main class="empty-state min-h-dvh">
  <p class="text-eyebrow text-rambutan-500">404</p>
  <h1 class="text-h1">This page doesn't exist</h1>
  <p class="text-body-sm">Go to the home page to find what you need.</p>
  <a href="/" class="btn btn-primary mt-4">Go home</a>
</main>

Give each empty state one action. Say what's missing and what to do next.

10.11 Soft badge

<span class="inline-flex items-center rounded-grape bg-pandan-500/15 text-pandan-200 px-2 py-1 text-label-sm inset-ring inset-ring-pandan-400/20">
  Live
</span>

Use the chosen tone's 500 shade at 15% opacity for the background. Pair it with 200 text and a 400 ring at 20% opacity. This pattern works with any accent or rambutan.

10.12 Outline badge

<span class="inline-flex items-center rounded-grape px-2 py-1 text-label-sm text-persimmon-300 inset-ring inset-ring-persimmon-400/30">
  Draft
</span>

10.13 Badge with icon

<span class="inline-flex items-center gap-1 rounded-grape bg-pandan-500/15 text-pandan-200 inset-ring inset-ring-pandan-400/20 py-1 pr-2 pl-1 text-label-sm">
  <svg viewBox="0 0 16 16" fill="currentColor" class="size-3.5 shrink-0"><!-- icon --></svg>
  Live
</span>

10.14 Select

<span class="inline-grid grid-cols-[1fr_--spacing(8)] rounded-grape bg-white/5 inset-ring inset-ring-white/10 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-persimmon-400">
  <select
    id="fruit"
    name="fruit"
    class="col-span-full row-start-1 appearance-none bg-transparent px-3 py-2 pr-8 text-body text-rambutan-100 focus:outline-hidden"
  >
    <option>Lychee</option>
    <option>Persimmon</option>
    <option>Durian</option>
  </select>
  <svg viewBox="0 0 8 5" width="8" height="5" fill="none" class="pointer-events-none col-start-2 row-start-1 place-self-center text-rambutan-400">
    <path d="M.5.5 4 4 7.5.5" stroke="currentcolor" />
  </svg>
</span>

For an error, use bg-lychee-500/5 inset-ring-lychee-400/40 focus-within:outline-lychee-400 on the wrapper.

For a disabled control, add disabled to <select>. Use opacity-60 bg-white/[0.02] inset-ring-white/5 on the wrapper.

For a leading icon, use grid-cols-[--spacing(9)_1fr_--spacing(8)]. Place the icon at col-start-1 row-start-1.

10.15 Checkbox

A checkbox supports checked, unchecked and partial selection states. Set input.indeterminate = true in JavaScript for a partial selection. This is a DOM property rather than an HTML attribute.

<div class="flex items-center gap-2 text-body text-rambutan-200">
  <span class="h-lh items-center inline-flex">
    <span class="group inline-grid size-5 sm:size-4 grid-cols-1">
      <input
        id="agree"
        name="agree"
        type="checkbox"
        class="col-start-1 row-start-1 appearance-none rounded-seed border border-white/10 bg-white/5 checked:border-persimmon-500 checked:bg-persimmon-500 indeterminate:border-persimmon-500 indeterminate:bg-persimmon-500 disabled:border-white/5 disabled:bg-white/10 disabled:checked:bg-white/10 forced-colors:appearance-auto"
      />
      <svg viewBox="0 0 14 14" fill="none" class="pointer-events-none col-start-1 row-start-1 size-7/8 self-center justify-self-center stroke-longan-950 group-has-disabled:stroke-white/25">
        <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="group-not-has-checked:opacity-0" />
        <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="group-not-has-indeterminate:opacity-0" />
      </svg>
    </span>
  </span>
  <label for="agree">I agree</label>
</div>

10.16 Radio

Use the checkbox structure with type="radio" and rounded-orb on the input. Replace the checkmark with this filled dot.

<span class="pointer-events-none col-start-1 row-start-1 size-[round(down,40%,1px)] self-center justify-self-center rounded-orb bg-longan-950 group-not-has-checked:opacity-0"></span>

10.17 Toggle

<div class="group relative inline-flex w-11 sm:w-9 shrink-0 rounded-orb bg-white/5 p-0.5 inset-ring inset-ring-white/10 outline-persimmon-500 outline-offset-2 has-checked:bg-persimmon-500 has-focus-visible:outline-2 transition-colors">
  <span class="aspect-square w-1/2 rounded-orb bg-white ring-1 ring-white/10 transition-transform group-has-checked:translate-x-full"></span>
  <input id="enabled" name="enabled" type="checkbox" aria-label="Enable" class="absolute inset-0 size-full appearance-none focus:outline-hidden" />
</div>

10.18 Divided grid

<ul role="list" class="grid grid-cols-3">
  <li class="p-6"><!-- first item: no left border --></li>
  <li class="p-6 [&:not(:nth-child(3n+1))]:border-l [&:not(:nth-child(3n+1))]:border-rambutan-100/10"></li>
  <li class="p-6 [&:not(:nth-child(3n+1))]:border-l [&:not(:nth-child(3n+1))]:border-rambutan-100/10"></li>
</ul>

Adjust dividers when the column count changes at a breakpoint.


11. Icons

Use Heroicons as the default set.

Match each icon's viewBox to its Tailwind size class. Keep the native dimensions.

Set viewBox Class
Heroicons Micro 0 0 16 16 size-4
Heroicons Mini 0 0 20 20 size-5
Heroicons Outline 0 0 24 24 size-6

Use Micro for app interfaces. Choose Mini for navigation and marketing pages. Reserve Outline for hero illustrations.

Rules

Add shrink-0 to icons inside flex or grid containers. Use h-lh to align with adjacent text.

Use fill-* for filled icons and stroke-* for outlines. Avoid text color utilities with currentColor.

Place icons directly in the layout. Skip decorative boxes and circles behind them.

Buttons with only an icon

Use btn btn-icon with an aria-label. On touch screens, it adds an invisible 48px square touch target around the button.

<button type="button" class="btn btn-ghost btn-icon" aria-label="Settings">
  <svg viewBox="0 0 16 16" fill="currentColor" class="size-4"><!-- icon --></svg>
</button>

12. Global document setup

The theme sets dark mode and font smoothing on <html>. It sets bg-longan-950 and text-rambutan-100 on <body>.

Monaspace Neon is the default font-sans and font-mono family. Every :focus-visible element gets a 2px persimmon outline at a 2px offset.

Start with this document structure.

<html lang="en">
  <body>
    <main class="isolate min-h-dvh">
      <!-- app -->
    </main>
  </body>
</html>

Use isolate on the main container to keep its stacking context separate from portalled elements. Use min-h-dvh for full viewport height.

Keep the default body colors and focus ring. Override the focus color when it carries meaning, as btn-danger does with lychee-400.


13. Accessibility

Give every form control a name and an accessible label. Connect a <label> through for and id, or use aria-label.

Set an explicit type on every button. Use submit to submit a form and button for other actions.

Add role="list" to <ul> and <ol> elements styled as flex or grid layouts. Safari can remove their list role when markers are hidden.

Keep touch targets at least 44px square. Use the invisible target pattern for small icon buttons.

Keep a visible focus ring. Replace it before removing the default outline. Use -outline-offset-1 on text fields to keep the ring inside.

Leave a gap between a field and its button, or place the button inside the field.


14. Required design rules

Use tone and inset-ring for depth. Bruhs omits Tailwind's drop shadows from shadow-xs through shadow-2xl.

Use fruit radius names. Default classes such as rounded-sm and rounded-full aren't part of Bruhs.

Use longan-950 for the canvas and rambutan for foreground details. Keep pure black and the gray, slate, zinc and neutral scales out of the palette.

Let .text-* roles set their fonts. Use font-semibold or font-medium for emphasis instead of font-bold.

Keep icons at their native sizes and place them directly in the layout.

Keep body text at least text-sm on screens wider than sm:. Use text-base on mobile. Reserve text-xs for labels and annotations.

Match a solid primary button's ring to its fill. Use one filled primary button per page or dialog.


15. Opacity conventions

When you need a softer tint of a color, use opacity modifiers on the scale:

Pattern Use for
-500/15 Soft backgrounds (badges, tinted panels)
-400/20 Soft ring pairing for -500/15 backgrounds
-400/30 Outline ring on a transparent background
white/5, white/10, white/15, white/20 Elevation rings on dark surfaces
longan-950/70 Modal / drawer scrim

16. Focus and states

Keep the theme's keyboard focus ring. Change its color or offset when needed, as in focus-visible:outline-lychee-400.

The input class keeps focus inside the field. For a custom field, add focus-visible:-outline-offset-1.

On hover, increase an elevated surface's ring opacity by five percentage points. For example, change white/5 to white/10. Lighten the background if needed.

For a pressed state, use a darker fill such as bg-persimmon-600. Use active:scale-[0.98] for subtle size feedback.

Use opacity-60 and cursor-not-allowed for disabled styles. Apply the disabled: variant to interactive children.


17. Writing

Lead with the point. Use the fewest words that keep the meaning.

Write short, complete sentences. Keep one idea per paragraph. Use fragments for labels when the action or meaning is clear.

Use sentence case in the interface, including chat messages and placeholders. Use lowercase for conversational replies outside the product. Preserve the case of product names and code identifiers.

Write instructions as short paragraphs. Use a table when readers need to compare values. Keep bullet points and dash punctuation out of UI copy. Preserve dashes required by code or URLs.

Name the action on each button. Say what failed and what to do next in errors.

Cut filler and repeated claims. Split packed sentences instead of joining more clauses.

Use tabular-nums for numbers that change. Add thousands separators, as in 1,284.


18. Native Mac apps

Native apps keep the system's controls and use Bruhs for their surfaces and content. Window buttons, toolbar items, search fields, menus, alerts and form controls stay standard AppKit and SwiftUI, in the system font. Bruhs sets what they sit on and what they show: the canvas and panel tones, colours, data type, radii and motion. Pith, in apps/storage/native, is the reference app.

Tokens

Generate Swift tokens from the theme instead of copying values. Run the same command with --check in CI; it fails when the file has drifted from the CSS.

bruhs-theme-swift Sources/App/Theme/BruhsTokens.swift
bruhs-theme-swift Sources/App/Theme/BruhsTokens.swift --check
Type Holds
BruhsColor The semantic roles in src/lib/semantic.ts, resolved for dark, light and Increase Contrast
BruhsNSColor The same roles as NSColor, for window backgrounds and AppKit drawing
BruhsPalette Every colour token as a Display P3 NSColor
BruhsRadius The fruit radius scale in points
BruhsMotion The duration scale, with standard, entrance and exit animations

Use BruhsColor roles for surfaces, text, hairlines, wells, accents, selection and status. Use BruhsPalette only for data, such as chart series or file kinds.

Change a role in semantic.ts, not in Swift. scripts/verify-tokens.mjs checks that every token fits Display P3 and that each readable role keeps its contrast on both background roles, canvas and surface, in all four appearances.

Rules

Follow the system appearance. The roles already resolve for light, dark and Increase Contrast, so don't force dark mode.

Paint windows and panels with canvas, under the toolbar too: set the window's backgroundColor to BruhsNSColor.canvas, set titlebarAppearsTransparent so macOS doesn't paint its grey band behind the toolbar, and keep content views transparent. Show a side panel of details only while something is selected. Make it a card on surface, with lychee corners and a hairline ring, inset 12 pt from the window's edges and from the toolbar so toolbar items never sit on it. Avoid SwiftUI's .inspector: macOS 26 draws it as a glass panel joined to the window's edge, with one rounded corner that shows as a notch against the canvas. Pith's BruhsSidePanel is a replacement that the user can resize and close. A menu bar popover takes the canvas edge to edge: turn on hasFullSizeContent so its arrow is part of the content, and size the popover to the panel plus the safe-area insets it reports.

Tint SwiftUI controls with .tint(BruhsColor.accent) at each window's root. The user's accent colour still marks focus and menus.

Draw scroll bars as the web theme does: a thin, rounded thumb in the scrollbar role, on no track. Pith's BruhsScroller does this and keeps AppKit's overlay fading and paging. Install it on AppKit scroll views, and add .bruhsScrollers() inside SwiftUI scroll views.

Set data in Monaspace: readouts, figures, sizes, paths and times. Keep labels, buttons and sentences in the system font. Register the bundled fonts at launch and check them in a smoke test.

Use the Bruhs accent for selection, the primary action and highlights in content, not the system accent colour. Show status with statusNormal, statusWarning and statusCritical, always alongside a symbol or words.

Keep shadows out of content. The system draws window and popover shadows; content uses tone and hairlines, as on the web.

Don't animate live data. Values and charts update in place. Use BruhsMotion for changes of state, such as entering or leaving a selected range.

Put a window's title and subtitle in its title bar or toolbar. Mac windows don't use large titles inside their content.

Set text in BruhsType styles rather than system text styles or fixed sizes. Add a style to the scale when a view needs one.

Keep bullets, middle dots and dashes out of UI text, both between figures and in place of a missing value. Lay the parts out as elements instead. Pith's LabeledFigure names a figure, as in "Peak 40%", and its BruhsBadge holds a process kind, version or count. Write ranges with "to". Leave a figure the app can't read empty and say why once per row; Pith puts a lock after the name of a process macOS protects.

Count storage in decimal units, as Finder does (995 GB), and memory in binary units, as Activity Monitor does. Pith's formatBytes and formatMemory hold the two rules.

Keep text at 4.5:1 or more on both background roles, tertiary text included: the HIG asks it of text under 18 pt, which is nearly all of a Mac app's text.

Use BruhsPalette hues for data only. The one exception is the disk map: its eight file kinds need eight hues that tell apart at a glance, so they use the whole fruit palette, the accent's and status colours' hues included. Where a kind shares the accent's hue, the selected block gets a ring of its label ink inside the accent.

Pith's scripts/lint-native.mjs fails on the system accent, system colours, large titles and text outside BruhsType in its views, and on bullets, middle dots and dashes in UI text there, in PithCore's strings and in its disk map, whether written as characters or as escapes. Copy it into a new app with its paths changed.


19. Dither

Dither is the brand texture: the Bruh and small indicators drawn in dots. The Bruh is a halftone on a hex grid: every dot is sized by the light on a sphere (dot area tracks brightness) and shrinks a little toward the rim, so the outline stays round and the face reads as a lit ball. Its eyes are holes and its mouth is one row of red. The level meter is one braille cell, two columns by four rows.

Pieces

Piece Where it lives Use for
bruhDots(), drawDots() @bruhs/theme/dither A dot Bruh on a canvas. It reads bruhGeometry, so it always matches the logo, and fits its dot grid to the size so the eye and mouth lines land exactly where the logo has them (gridFor())
lookX, lookY bruhDots() face options Turning the head in 3D: the features ride the sphere and the ball leans the way it looks
meterDots() + .braille-meter dither module + component class A live level, such as microphone input, as one braille cell
.dither-rule component class A dotted divider between sections of a brand page
import { bruhDots, drawDots } from "@bruhs/theme/dither";

const dots = bruhDots(width, height, { cx: width / 2, cy: height / 2, r: width * 0.42 });
drawDots(ctx, width, height, dots, { face: base, mouth }); // resolved bruh-base, bruh-mouth
<span class="braille-meter" role="meter" aria-label="Microphone" aria-valuenow="0.4" aria-valuemin="0" aria-valuemax="1">
  <i></i><i></i><i data-lit></i><i data-lit></i>  <!-- left column, top to bottom -->
  <i></i><i></i><i></i><i data-lit></i>            <!-- right column -->
</span>

Page transitions

Brand sites can dissolve between pages through a dot screen with cross-document view transitions. CSS only: the next page grows in through four interleaved dot layers in 2×2 Bayer order, about 300ms in all.

@view-transition { navigation: auto; }
@property --dot-a { syntax: "<length>"; inherits: false; initial-value: 0px; }
/* …--dot-b, --dot-c and --dot-d the same */
::view-transition-old(root) { animation: none; }
::view-transition-new(root) {
  mask-image: radial-gradient(circle, #000 var(--dot-a), transparent calc(var(--dot-a) + .5px)) /* , …b, c, d */;
  mask-size: 16px 16px;
  mask-position: 0 0, 8px 8px, 8px 0, 0 8px;
  animation: dot-a 200ms var(--ease-entrance) both, dot-b 200ms var(--ease-entrance) 33ms both /* , … */;
}
@keyframes dot-a { to { --dot-a: 6px; } } /* 6px covers an 8px cell's corners */
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }

bruhs.ai is the reference: bruhs-site/src/styles/site.css in bruhs-ai.

Rules

Keep dither for brand moments: landing pages, about and update screens, onboarding, and live indicators such as a recording meter. Leave it out of dense app chrome, lists and anything typed or navigated by keyboard.

Never make an action wait for a dither effect. Show the new state at once and let the dots resolve over it, under 300ms, or skip the effect.

Draw a Bruh static by default. A reveal or a face that follows the pointer is for a page's first view, once.

Remove dither motion for prefers-reduced-motion: draw the finished dots and navigate without a transition.

Keep the mascot's own colours for a dot Bruh (bruh-base and bruh-mouth) and longan-700 for rules and unlit meter dots. Use the meter's fruit colours to tell its two inputs apart, and say what each column is in its label.

Treat a dot Bruh as decoration. Hide its canvas from assistive technology, and keep the words that matter in text.


20. When in doubt

Start with whitespace. Add a divider or surface only when the content needs more separation.

Build new patterns from the existing tokens. Use the classes in section 10 across frameworks.


Bruhs is a personal design system by bnle.me. This guide takes precedence over older documentation.