Naalya Handbook
Design System

Styling

The Hub's minimal, ring-based visual language — quiet cards, what each OKLch token means, and the spacing and radius scale.

The Education Hub has a deliberately quiet look. Surfaces are flat, color shows up sparingly, and almost nothing has a hard edge. When you build a new screen, the goal isn't to decorate it — it's to make it disappear into the rest of the app. This page is the visual language that gets you there: how cards stay minimal, why everything is outlined with a ring instead of a border, and the token, spacing, and radius vocabulary underneath it all.

If Components & Patterns is the kit of blocks, this is the finish every block shares.

Rings, not borders

The single most defining choice in the whole UI: structure is drawn with rings, not borders. Look at the base Card — it has no border at all, just a hairline ring and a barely-there background tint:

src/components/ui/card.tsx
<div
  className="bg-card text-card-foreground rounded-xl py-4 ring-1 ring-foreground/10 group/card ..."
/>

Why a ring and not a border? Three reasons that add up to a calmer interface. A ring is painted inside the box, so it never nudges layout the way a border's extra pixel does — and hover states can thicken or color it with zero reflow. It reads softer than a 1px line. And because it's just box-shadow underneath, it layers cleanly over the faint card tint instead of fighting it.

Reach for a ring before a border or a shadow

Borders and drop shadows both exist in the theme, but the house style leans on rings first. The shadow tokens (shadow-xs, shadow-sm) are intentionally tiny — elevation here comes from a ring and a hair of background tint, not a heavy float. A border-t shows up in exactly one common place: a CardFooter, to fence off an action bar.

Minimal cards

Cards are plain on purpose. The --card background is nearly white — oklch(0.993 0.0005 260), a whisper warmer than the page — so a card reads as a surface without a slab of contrast. The title is text-base font-medium, the description is text-muted-foreground, the corners are rounded-xl, and that's the whole costume. The content is what carries the card, not its chrome.

That restraint is what lets the ring system do the talking. A resting card is silent; an interactive one wakes up only on hover; a selected one holds a slightly louder ring. Nothing is shouting at rest.

The ring vocabulary

There's really one device — the ring — used at a few opacities, and the opacity is the signal. Heavier means "more important / more active":

RingMeans
ring-1 ring-foreground/10the structural hairline on a resting card or container
hover:ring-1 hover:ring-primary/40an interactive tile waking up under the cursor (lists, hub tiles)
ring-1 ring-primary/60a selected card, or a school entity that wants more weight
outline-ring/50the keyboard-focus ring every element gets for free

A clickable tile is just the resting card plus a hover ring — one className, no border swap:

an interactive tile
<Card className="bg-muted hover:ring-1 hover:ring-primary/40 transition-all cursor-pointer">

The last row matters for accessibility: the base layer applies outline-ring/50 to every element, so focus is consistent and on-brand without you wiring it per component.

OKLch tokens

You never reach for a raw hex or a Tailwind palette color (blue-500, gray-200) — you reach for intent. Every color is a semantic token defined as an OKLch CSS variable in src/styles.css and exposed to Tailwind through @theme inline. These are the ones you'll actually type:

TokenUse it for
text-foregroundprimary text — titles, values
text-muted-foregroundsecondary text — descriptions, metadata, hints
bg-carda raised surface (a card, a popover)
bg-muted / bg-muted/50quiet fills — tiles, disabled rows, footers
bg-primary/10 text-primaryan accent wash — the "current" pill, active nav
ring-foreground/10 / ring-primary/40the rings above
destructive · warning · info · successthe four fixed status colors

The status four are fixed — they never re-tint to a school's brand, so a destructive action is always the same red. Everything else (the primary and the neutrals) is re-derived per school at runtime, which is why you must use the token and never a literal — see Per-School Theming. The same tokens flip wholesale in dark mode; because you only ever named the intent, your UI follows along with no extra work.

The canonical accent you'll reuse most is the "current" pill:

the 'current' pill
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
  Current
</span>

Spacing & radius

Radius flows from one variable. --radius is 0.7rem in light mode (a noticeably rounded feel) and the rounded-* steps are derived from it, so the whole app curves consistently:

  • rounded-xl — the default for cards and containers
  • rounded-lg — smaller chrome like an icon well
  • rounded-full — pills, avatars, the search affordance

Spacing leans on a small, repeated set rather than bespoke numbers: pages pad with p-4 md:p-6, cards use py-4 px-4, and grids breathe with gap-4 when tight or gap-6 when the cards are larger. Stick to these rungs and a new screen lines up with its neighbours automatically.

One global transition

You rarely write a transition class, because the stylesheet already gives interactive elements one:

src/styles.css
button, input, textarea, select, [role='button'] {
  @apply transition-all duration-200;
}

So when you add hover:ring-primary/40 or a color shift, it eases in on its own — re-declaring transition-all is just noise. The same base layer hands every element the outline-ring/50 focus ring, so smooth, on-brand interactions are the default, not something you assemble.

The recipe for "feels like the Hub"

Put it together and a screen that belongs here is doing five quiet things:

  1. A flat bg-background page — no slab of color behind the content.
  2. Content sitting in bg-card cards outlined with a ring, never a border.
  3. text-muted-foreground for anything secondary, so the hierarchy reads at a glance.
  4. primary used only as an accent — a ring, a pill, the active nav item — never as a fill behind big areas.
  5. rounded-xl corners and the shared spacing rungs, riding the one global transition.

When in doubt, do less

If a card needs a border and a shadow and a background color to feel finished, it's usually carrying too little content or too much chrome. Strip back to a ring and a tint first — the minimal version is almost always the on-brand one.

Where to go next

On this page