# nol.satu design system · v2.0

> Clean bones. Colorful parts.

nol.satu is a builder for makers: every site and page people make is assembled from the same small blocks. This system keeps the frame quiet and precise, so the blocks, and the people arranging them, get the colour.

- Live reference: https://design.nolsatu.id
- Source of truth: [`tokens/tokens.json`](tokens/tokens.json) (W3C Design Tokens format)
- Tailwind v4 theme: [`tokens/tailwind.css`](tokens/tailwind.css) · plain CSS: [`tokens/nolsatu.css`](tokens/nolsatu.css)
- Resolved values per mode: [`tokens/tokens.resolved.json`](tokens/tokens.resolved.json) · contrast report: [`tokens/contrast.json`](tokens/contrast.json)

If this file and `tokens.json` ever disagree, `tokens.json` wins.

---

## 1. Principles

1. **Quiet frame, colourful parts.** UI chrome is stone neutrals. Category colour belongs to blocks. Teal marks what you can act on.
2. **One primary action per view.** Teal is loud because it's rare.
3. **Both themes are first-class.** Every semantic token has a light and a dark value, and every pair is contrast-checked in both.
4. **Plain words.** Name what the person is doing, not how the system works.

## 2. Brand

### Story

nol.satu started the way a lot of small businesses do, with two founders, one laptop and more ideas than time. Mutia took the parts that needed patience. Alshaadiq took the parts that needed nerve. Every feature still has to pass the day-one test: would this have made things easier for two people just starting out?

| | |
|---|---|
| Founded by | Mutia & Alshaadiq |
| Started as | A tool for their own studio |
| First customer | Each other |
| Now building for | Anyone starting out |

### The mark

Two capsules, one for each founder, lean toward each other like two people mid-conversation. Where they cross, a third shape appears. That shape is the company. In v2 the forms are drawn as lines, so the overlap is the only solid part of the mark.

**Construction** (56 × 56 grid): two 24 × 44 capsules (radius 12), centres 16 units apart, each leaning 8° toward the other. Stroke 4.5 units, or 6 units in `mark-small` for 24px and below.

| Part | Light | Dark |
|---|---|---|
| Left form (first founder) | berry 600 `#A3245A` | berry 300 `#EB7BA8` |
| Right form (second founder) | teal 600 `#0F7466` | teal 400 `#4CC9B0` |
| Overlap (the company) | teal 900 `#0E332D` | stone 50 `#F3F1EC` |

**Files** in [`assets/logo/`](assets/logo/). Every file is an outlined SVG with no font dependency.

| File | Use |
|---|---|
| `lockup-horizontal[-dark\|-mono\|-ink\|-white].svg` | Default logo: app header, site footer, docs |
| `lockup-stacked[...].svg` | Square-ish spaces: splash, social avatars with room |
| `wordmark[...].svg` | When the mark already appears nearby |
| `mark[...].svg` | Icon-only placements, 25px and up |
| `mark-small[...].svg` | 16–24px: favicons, tabs, dense toolbars |
| `favicon.svg` | Follows the OS light/dark setting |
| `app-icon.svg`, `app-icon-light.svg`, `app-icon-dark.svg` | Full-bleed squares; the platform applies the corner mask |
| `../favicon-32.png`, `../apple-touch-icon.png`, `../app-icon-192.png`, `../app-icon-512.png`, `../og.png` | Raster exports |

Colourways: *(none)* colour on light, `-dark` colour on dark, `-mono` uses `currentColor`, `-ink` single-colour stone 900, `-white` for teal fills and photos.

**Rules**
- Clear space: half the mark's width on every side of a lockup.
- Minimum size: mark 16px; horizontal lockup 88px wide.
- Don't rotate, straighten, fill or recolour the forms. It's berry and teal, or one colour.
- Don't place the colour mark on teal. Use `-white`.
- Always write the name in lowercase with its dot, **nol.satu**, even at the start of a sentence.

## 3. Colour

### Palette (primitives)

Use these only for illustration and the mark. Product code uses semantic tokens.

| Family | Steps |
|---|---|
| **teal** (brand) | 50 `#F0F8F6` · 100 `#E2F2EE` · 200 `#BFE5DC` · 300 `#86DCCB` · 400 `#4CC9B0` · 500 `#1E9985` · 600 `#0F7466` · 700 `#0B5C51` · 800 `#0B463F` · 900 `#0E332D` · 950 `#06201B` |
| **berry** (accent, mark) | 50 `#FCF0F5` · 100 `#F8DCE8` · 200 `#F2B9D1` · 300 `#EB7BA8` · 400 `#DB5590` · 500 `#C23A74` · 600 `#A3245A` · 700 `#841D49` · 800 `#641638` · 900 `#3F0E24` |
| **stone** (neutrals) | 0 `#FFFFFF` · 25 `#FAF9F6` · 50 `#F3F1EC` · 100 `#EAE7E0` · 200 `#E3DFD6` · 300 `#CFC9BE` · 400 `#A69E93` · 450 `#968E83` · 500 `#857D72` · 550 `#736B62` · 600 `#6B645C` · 700 `#4A443E` · 800 `#34302A` · 850 `#24201C` · 900 `#1C1916` · 925 `#181613` · 950 `#14120F` |
| green / amber / red / blue | Status only; see `tokens.json` |

### Semantic tokens

| Token | Tailwind | Light | Dark | Use for |
|---|---|---|---|---|
| `color.canvas` | `bg-canvas` | `#FAF9F6` | `#14120F` | App background |
| `color.surface` | `bg-surface` | `#FFFFFF` | `#1C1916` | Cards, panels, inputs, top bar |
| `color.surface.subtle` | `bg-surface-subtle` | `#F3F1EC` | `#181613` | Sidebars, table headers, wells |
| `color.surface.hover` | `bg-surface-hover` | `#EAE7E0` | `#24201C` | Hover and selected rows |
| `color.surface.inverse` | `bg-surface-inverse` | `#1C1916` | `#F3F1EC` | Tooltips, toasts |
| `color.line` | `border-line` | `#E3DFD6` | `#34302A` | Dividers, card borders |
| `color.line.strong` | `border-line-strong` | `#CFC9BE` | `#4A443E` | Emphasised dividers, drop zones |
| `color.line.control` | `border-line-control` | `#968E83` | `#736B62` | Input and checkbox borders (≥ 3:1) |
| `color.fg` | `text-fg` | `#1C1916` | `#F3F1EC` | Primary text |
| `color.fg.muted` | `text-fg-muted` | `#6B645C` | `#A69E93` | Secondary text |
| `color.fg.subtle` | `text-fg-subtle` | `#857D72` | `#857D72` | Placeholders, disabled only |
| `color.brand` | `bg-brand` / `text-brand` | `#0F7466` | `#4CC9B0` | Primary actions, selection, links |
| `color.brand.hover` / `.active` | `bg-brand-hover` | `#0B5C51` / `#0B463F` | `#86DCCB` / `#BFE5DC` | States |
| `color.brand.subtle` | `bg-brand-subtle` | `#E2F2EE` | `#0E332D` | Selected backgrounds, drop targets |
| `color.brand.fg` | `text-brand-fg` | `#0B5C51` | `#86DCCB` | Brand text on canvas or subtle |
| `color.on-brand` | `text-on-brand` | `#FFFFFF` | `#06201B` | Text on `brand` |
| `color.accent` | `bg-accent` | `#A3245A` | `#EB7BA8` | Rare emphasis: "new" badges, highlights |
| `color.focus` | `ring-focus` / `outline-focus` | `#0F7466` | `#4CC9B0` | 2px ring, 2px offset |
| `color.scrim` | `bg-scrim` | 48% stone 950 | 64% black | Behind modals |
| `color.{success,warning,danger,info}` | `bg-success` … | | | Solid fills with `text-on-*` |
| `color.{…}.subtle` / `.fg` | `bg-danger-subtle text-danger-fg` | | | Alerts and badges |

### Block categories

Every block belongs to one of six categories. Its colour appears as a tag, a dot, the drag handle and the canvas outline while it's being placed. It never appears in UI chrome.

All six are generated in OKLCH at matching lightness and chroma (light: solid L0.64 C0.16, subtle L0.95 C0.045, fg L0.42 C0.11; dark: solid L0.74 C0.15, subtle L0.30 C0.06, fg L0.88 C0.09). Every tag passes 7:1.

| Category | Hue | Meaning | Solid L / D | Subtle L / D | Fg L / D |
|---|---|---|---|---|---|
| action | 8 | Blocks that do something: buttons, forms, sign-ups | `#DA5B79` / `#F87F99` | `#FFE8EB` / `#472028` | `#7D2E41` / `#FEC7CF` |
| idea | 85 | Words and messages: headlines, text, quotes | `#B08505` / `#D6A20A` | `#FCEDCD` / `#3C2B02` | `#624800` / `#F3D392` |
| structure | 255 | Layout and data: sections, columns, pricing tables | `#408DEA` / `#6DADFF` | `#E4F0FE` / `#182E4B` | `#1D4D87` / `#BEDAFE` |
| growth | 130 | Reach and results: newsletter, analytics, SEO | `#6B9E1F` / `#8BBD50` | `#E5F5D6` / `#243411` | `#385805` / `#C4E4A5` |
| flow | 48 | Paths people follow: navigation, checkout, steps | `#D76821` / `#F68A4F` | `#FFEADF` / `#462311` | `#7B3606` / `#FECBB1` |
| warmth | 310 | Human touches: testimonials, team, social proof | `#AA6CD4` / `#C88EF1` | `#F5E9FF` / `#372544` | `#5F3979` / `#E7CAFF` |

Tailwind: `bg-cat-flow-subtle text-cat-flow-fg`, dot `bg-cat-flow`.

### Accessibility

`npm run tokens` checks 43 pairings in both themes and fails the build if any drop below 4.5:1 for text or 3:1 for control boundaries, focus rings and category dots. See [`tokens/contrast.json`](tokens/contrast.json). Never use colour as the only signal: status badges carry an icon or a word, and category tags carry their name.

## 4. Typography

| Role | Family | Weights | Use |
|---|---|---|---|
| Display | Space Grotesk | 600–700 | Headings, the wordmark, numbers that matter. Never below 16px. |
| Sans | Instrument Sans | 400–600 | Everything people read and click |
| Mono | IBM Plex Mono | 400–500 | Uppercase labels, category tags, URLs, shortcuts, code |

All three are on Google Fonts:
`https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Instrument+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap`

| Style | Tailwind | Size | Line | Weight | Tracking | Notes |
|---|---|---|---|---|---|---|
| display-xl | `type-display-xl` | 56px (fluid from 40) | 1.05 | 700 | -0.035em | Marketing hero only |
| display-lg | `type-display-lg` | 44px (fluid from 32) | 1.1 | 700 | -0.03em | Section heads, empty-state heroes |
| h1 | `type-h1` | 34px (fluid from 28) | 1.15 | 600 | -0.025em | Page title |
| h2 | `type-h2` | 26px (fluid from 22) | 1.2 | 600 | -0.02em | Section title |
| h3 | `type-h3` | 20px | 1.3 | 600 | -0.015em | Panel and modal titles |
| h4 | `type-h4` | 16px | 1.35 | 600 | -0.01em | Card and block titles |
| body-lg | `type-body-lg` | 17px | 1.6 | 400 | 0 | Marketing, onboarding |
| body | `type-body` | 15px | 1.55 | 400 | 0 | Default UI text |
| body-sm | `type-body-sm` | 13px | 1.5 | 400 | 0 | Sidebars, tables, helper text |
| caption | `type-caption` | 12px | 1.4 | 500 | 0.005em | Timestamps, meta |
| label | `type-label` | 11px | 1.45 | 500 | 0.07em | UPPERCASE mono labels |
| code | `type-code` | 13px | 1.55 | 400 | 0 | Code, URLs, IDs |

Fluid sizes scale linearly between a 400px and a 1280px viewport. Use sentence case everywhere, tabular numbers in tables and counters, and keep running text under 66 characters wide.

## 5. Space, size, radius

**Spacing**: 4px base. In Tailwind `--spacing` is 4px, so `p-4` = 16px. Tokens: 0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96px.

**Radius**: corners get softer as objects get bigger.

| Token | Value | Use |
|---|---|---|
| `rounded-2xs` | 4px | Checkboxes, kbd |
| `rounded-xs` | 6px | Menu items, tooltips |
| `rounded-sm` | 12px | Buttons, inputs, selects, toasts |
| `rounded-md` | 18px | Cards, blocks, popovers |
| `rounded-lg` | 24px | Modals, sheets, large panels |
| `rounded-full` | pill | Tags, toggles, avatars |

**Controls**: sm 28px (toolbars, dense tables), md 34px (default), lg 42px (marketing, onboarding). Icons: 16 / 20 / 24px, 1.75 stroke, rounded caps. Lucide matches the monoline mark.

**Focus**: 2px `focus` ring with 2px offset on every interactive element, keyboard only (`:focus-visible`).

## 6. Elevation & layering

Most surfaces are flat with a 1px `line`. Shadows are for things that float. In dark mode, shadows add a faint light edge so they still read.

| Token | Use |
|---|---|
| `shadow-xs` | Buttons, inputs |
| `shadow-sm` | Selected block, raised card |
| `shadow-md` | Menus, popovers, a block being dragged |
| `shadow-lg` | Modals, command palette |

z-index: base 0 · raised 1 · sticky 10 · dropdown 100 · overlay 200 · modal 300 · toast 400 · tooltip 500 (`--ns-z-*`).

## 7. Motion

Motion confirms what happened. It never performs.

| Duration | Value | | Easing | Curve | Use |
|---|---|---|---|---|---|
| fast | 120ms | | standard | `cubic-bezier(0.2, 0, 0, 1)` | Most transitions (Tailwind default) |
| base | 180ms | | enter | `cubic-bezier(0, 0, 0.2, 1)` | Things appearing |
| slow | 280ms | | exit | `cubic-bezier(0.4, 0, 1, 1)` | Things leaving |
| slower | 420ms | | snap | `cubic-bezier(0.34, 1.4, 0.64, 1)` | A block landing after a drag. Blocks only. |

With `prefers-reduced-motion: reduce`, every duration drops to 0.

## 8. Components

Recipes use Tailwind v4 with `tokens/tailwind.css`.

**Buttons**: one primary per view. Labels are verbs that say exactly what happens ("Publish site", not "Submit").
- Primary: `h-[34px] rounded-sm bg-brand px-3.5 text-[13.5px] font-medium text-on-brand shadow-xs hover:bg-brand-hover`
- Secondary (default): `border border-line-control bg-surface text-fg shadow-xs hover:bg-surface-hover`
- Ghost (toolbars): `text-fg hover:bg-surface-hover`
- Danger: `bg-danger text-on-danger`, used only for the destructive action itself
- Sizes: sm 28px · md 34px · lg 42px. Disabled: 45% opacity, no shadow.

**Form fields**: label above (`type-body-sm font-medium`); input `h-[34px] rounded-sm border border-line-control bg-surface px-3 placeholder:text-fg-subtle focus:border-brand focus:ring-3 focus:ring-brand/20`; hint `type-caption text-fg-muted`; the error replaces the hint, in `text-danger-fg` with an alert icon, and the border turns `border-danger`. URLs and slugs use the mono font.

**Selection**: checkbox 18px `rounded-2xs border-[1.5px] border-line-control`, checked `bg-brand`. Toggle 36 × 20 `rounded-full bg-line-strong`, on `bg-brand`. Segmented control: `bg-surface-hover` track, selected item `bg-surface shadow-xs`.

**Tags**: category only. `h-[22px] rounded-full bg-cat-{c}-subtle text-cat-{c}-fg` in mono 11px lowercase, with a 6px `bg-cat-{c}` dot.

**Badges**: status only. `h-[22px] rounded-xs bg-{status}-subtle text-{status}-fg`, always with an icon or a word. The berry "new" badge is the only use of `accent` in UI chrome.

**Blocks** (the core object): flat `bg-surface border-line rounded-md p-3.5 shadow-xs`. Selected: `ring-2 ring-brand` with a transparent border. Dragging: `-rotate-1 shadow-md`, then it lands with `ease-snap`. Drop target: `bg-brand-subtle border-dashed border-brand`.

**Feedback**: toasts use `bg-surface-inverse text-fg-inverse rounded-sm shadow-md`, confirm with a teal check and disappear after 4s. Inline alerts use `bg-{status}-subtle` with a `{status}-fg` icon and title, and stay until the problem is fixed.

**Overlays**: menus `bg-surface border-line rounded-md shadow-md p-1.5` with `rounded-xs` items. Tooltips `bg-surface-inverse text-fg-inverse rounded-xs` 12px. Modals `rounded-lg shadow-lg p-5.5` over `bg-scrim`, with actions right-aligned and the confirming action last.

**Tabs**: text-only with a 2px `border-brand` underline on the selected tab.

**Empty states**: dashed `border-line-strong rounded-lg`, an h3 title, one sentence, a primary and a secondary action.

## 9. Voice

Plain, active, never precious. Write from the maker's side of the screen.

1. **Name the action, then confirm it.** An action keeps its name from click to confirmation: Publish → Publishing… → Published.
2. **Errors say what to do next.** What happened, then how to fix it. No apologies, no error codes up front.
3. **Sentence case, short sentences.** One idea per sentence. Buttons are two or three words. No exclamation marks in the UI.
4. **Specific beats clever.** "3 fields", "6 blocks", "under an hour".

| Avoid | Use |
|---|---|
| "Your webhook configuration has been submitted for processing." | "Saved. Your form now sends replies to your inbox." |
| "Error: operation failed unexpectedly." | "Couldn't publish. Check your domain settings and try again." |
| "Are you sure you want to proceed?" | "Delete "About"? This removes the page and its 6 blocks." |
| "Successfully saved!" | "Saved." |
| "Invalid input." | "Use letters, numbers and hyphens only." |

## 10. Using the tokens

In the nol.satu monorepo, add `"@nolsatu/design-system": "*"` to the app's dependencies:

```css
/* apps/<app>/src/app.css (Tailwind v4) */
@import "tailwindcss";
@import "@nolsatu/design-system/tailwind.css";
```

Outside the monorepo, copy https://design.nolsatu.id/tokens/tailwind.css into your project and import it instead.

Tailwind's default colour, radius, shadow, text-size and easing scales are switched off, so only nol.satu values are available.

**Theme switching**: colours follow the OS by default. To let people choose, set `data-theme="light" | "dark"` on `<html>` before first paint. Tailwind's `dark:` variant follows the same attribute, and any subtree can set its own `data-theme`.

```html
<script>try{const t=localStorage.getItem("ns-theme");if(t)document.documentElement.dataset.theme=t}catch{}</script>
```

**Naming**: path segments join with `-` and `default` is dropped. `color.surface.subtle` → `--ns-color-surface-subtle` → `bg-surface-subtle`. `typography.h2` → `--ns-text-h2-*` → `type-h2`.

**Without Tailwind**: import `tokens/nolsatu.css` and use `var(--ns-…)` plus the `.ns-type-*` classes.

## 11. Changelog

**2.0.0, 30 Sep 2026**
- New direction: a crisp modern tool. Stone neutrals replace ink and cream; light and dark are equal.
- Teal forest leads; berry becomes the accent and lives in the mark.
- Block categories are retuned in OKLCH. Structure moves to cobalt and Warmth to orchid. All pass AA.
- The mark is redrawn as monoline on a 56-unit grid; 29 SVGs plus PNG exports.
- Body font Inter → Instrument Sans; mono JetBrains Mono → IBM Plex Mono; fluid type scale.
- Added status colours, elevation, motion, focus, z-index, control sizes and a contrast report.
- Ships as tokens.json → CSS variables + Tailwind v4, DESIGN.md, design.nolsatu.id and a PDF.

**1.2**: original PDF and logo PNGs (archived in `archive/v1.2/`).
