Skita

new

For task boards and tools people keep open all day.

Skita fits a lot on screen without feeling cramped. Most text is monospaced, the palette is grey with a green accent, and controls are compact. It suits project trackers, issue boards and other work tools.

21 primitives4 extras.ds-skita

A whole screen

The foundation plus the four Skita extras: a project home with the panel inset on the sidebar's canvas.

Projects /Skita web

Skita webBeta

Week 45. Three features in flight, one due Friday.

Shipped

7

Two more than last week

Open tasks

18

Across three features

In review

4

Waiting on a second pair

Feedback

12

Four new since Monday

Tasks
Open first, then by due date.
TaskDue
Onboarding checklistToday
Retry Stripe webhooksDone
Drag to reorder roadmapFri
Export docs to PDF—

Agent

Release notes
From 7 merged PRs
SKI-108 merged
Moved to Done

Runs in the background.

01

Principles

01

Mono by default

Geist Mono is the body face, so the interface reads like a well-set terminal. Instrument Sans appears only where a page names itself or a number is the point.

02

One green, two jobs

Green paints the one action and marks work as done. The fill (primary) and the text (primary-ink) split in dark mode, so each stays legible in its role.

03

Tints, not solids

Status lives behind words as a 10 to 15% tint. Even the destructive button is a red tint. The only solid colour on screen is the primary button.

04

Rings, not borders

Cards, menus and dialogs carry a ring of the text colour at 10%, which reads the same on white, zinc and black. Shadows are Tailwind's xs to md, used sparingly.

05

Dense, then a little larger

Compact controls (sm is the everyday button) drawn 10% larger on desktop, and small type that starts at 14px on phones, so it reads at 100% zoom.

06

Quiet motion

Hover fades over 150ms, a press nudges down 1px, dialogs arrive in 100ms and menus open instantly. Focus is a neutral zinc ring: green is never spent on focus.

02

Installation

1

Add the system

pnpm dlx shadcn@latest add @satoriui/skita

One command installs the shadcn/ui foundation it restyles (21 primitives), the stylesheet at styles/skita.css and the extras under components/satori-ui/skita/. Primitives you already have are left alone.

2

Import the stylesheet

app/globals.css
@import "tailwindcss";
@import "tw-animate-css";
@import "../styles/skita.css";

The file carries its own @theme and @utility blocks, so it has to be imported where Tailwind can see it.

3

Scope it

app/layout.tsx
<html lang="en" className="ds-skita">

Every token lives under .ds-skita. On html it themes the whole app. On a subtree it themes that subtree, and anything that portals out of it (dialogs, menus, tooltips) needs the class too.

4

Load the fonts

app/layout.tsx
import { Geist_Mono, Instrument_Sans } from "next/font/google";
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" });
const heading = Instrument_Sans({ subsets: ["latin"], variable: "--font-instrument-sans" });
<body className={`${mono.variable} ${heading.variable}`}>

Outside Next.js, @fontsource-variable/geist-mono and @fontsource-variable/instrument-sans work too: the stack also names both families directly.

5

Teach cn() the type scale

lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [{ text: ["2xs", "3xs"] }],
},
},
});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

Out of the box, tailwind-merge reads text-2xs as a text colour and silently drops it from cn("text-2xs", "text-foreground"). The extras do not depend on this, but your own code will.

03

Foundations

Colour

Grey for almost everything, with green as the only accent. Status colours appear as soft tints behind words.

Action

  • primaryThe primary button, checked controls, the filled tile
  • primary-inkGreen as text: links, the active nav row, badges
  • primary-tintThe default badge, the active nav row, icon wells

Status

  • warningIn progress. Shown as a tint, or an icon colour
  • infoIn review. The one tint that keeps an outline
  • dangerDestructive and overdue. A tint even on buttons

Surfaces

  • canvasThe sidebar and the ground the panel sits on
  • panelThe content sheet and cards
  • mutedTab tracks, hovers, skeletons, selected rows
  • raisedMenus and dialogs

Text and lines

  • foregroundBody, headings and table headers
  • mutedDescriptions, IDs, timestamps
  • borderFields, rows and the panel edge
  • ringFocus: neutral, never green

Type

Two faces. Geist Mono carries the interface; Instrument Sans carries page titles and headline numbers. Sizes are quoted at the 110% desktop scale.

The quick brown foxstat22px · Instrument · 500
The quick brown foxpage title19.8px · Instrument · 500
The quick brown foxcard title15.4px · Mono · 500
The quick brown foxbody13.2 / 21.5 · Mono · 400
The quick brown foxsmall12.1px · Mono · 400
The quick brown foxlabel11px · +0.05em · 500

Interface

Geist Mono

Every label, field, table, button and card title. Tabular figures come free.

Titles

Instrument Sans

Opt-in with font-heading: page titles and headline numbers, nothing else.

Shape and elevation

One radius and fractions of it. Depth is a 10% ring or one of Tailwind's small shadows, rarely both.

Radii

box4px
tag6.6px
control8.8px
card11px
dialog15.4px

Elevation

ring
xs
sm
md
  • ringCards, menus, dialogs: the text colour at 10%
  • xsThe content panel on the canvas
  • smStat tiles
  • mdMenus and select lists
04

Components

Every demo below is the stock shadcn/ui component. Nothing in components/ui was edited: the stylesheet does all of it. The Code tab shows exactly what you would write.

Buttons

sm is the everyday size. Hover fades the fill to 80%, a press nudges down 1px, and destructive is a red tint rather than a solid.

Badges

Square-cornered tags at the smallest step. Every colour is a tint behind a word; status hues come from the tint-* utilities.

ShippedDraftBacklogBlocked
DoneIn reviewIn progressOverdue

Fields

A zinc edge and no fill. Focus is a neutral ring; an error keeps a red ring and replaces the hint. 16px on phones.

New feature

It lands on the roadmap as Planned.

Shown on the public changelog.

Letters only, no spaces.

Selection controls

A 4px box and a circle, filled green when on. Settings sit in an inset grouped list with a small, flat switch trailing.

Board

Group by

Notifications

Mentions
When someone tags you in a task.
Merged pull requests
Agent summaries
A digest every Monday.

Tabs

A muted track with a lifted white trigger for sections, and the line variant for views of the same data.

Three features in flight. The roadmap moves to Q1 on Friday.

Cards and lists

Cards take a 10% ring instead of a border. Only one StatTile per row is filled green, so it stands out.

Shipped

7

This week

Docs

12

Two edited today

Members

5

One invite pending

Connect GitHub
Merged pull requests close their tasks.
Skita reads branch names like ski-108-retry and links them for you.

Project

Visibility
Members
Issue prefix
SKI
Default view
Board

Only owners can change these.

Table

Headers in the foreground at medium weight, status as an icon colour, a half-muted hover and a muted selected row.

TaskStatus
SKI-112Onboarding checklistIn progress
SKI-110Invite by linkIn review
SKI-108Retry Stripe webhooksDone
SKI-104Drag to reorder roadmapTo do

Dialog, menu and tooltip

Floating surfaces share one radius and a ring. Menus open instantly, the dialog fades in over 100ms, the tooltip is inverted.

Loading

The cascade for work measured in minutes, a skeleton for content on its way, a 4px bar for a known amount.

Agent is planning the week

Reading 18 open tasks

Importing from Linear64%
05

Extras

The components this system adds on top of the foundation. They install to components/satori-ui/skita/ and read the same tokens as everything else.

StatTile

A KPI tile with the number in Instrument Sans. One filled, green tile per row at most.

components/satori-ui/skita/stat-tile.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* A KPI tile.
*
* At most one filled tile per row: green is the one colour Skita spends on
* emphasis, and two filled tiles means neither is the point.
*
* The label is uppercase at the 3xs step because it is data *about* the
* number rather than the number itself. The number is the one place inside
* a card that switches to the heading face. The caption explains the number
* and never repeats it: "Across four projects" under 34, not "34 tasks".
*
* Inner elements join their classes with plain strings rather than `cn()`:
* an unconfigured tailwind-merge reads `text-3xs` as a colour and drops it
* when `text-muted-foreground` follows.
*/
export function StatTile({
label,
value,
caption,
icon,
filled = false,
className,
...props
}: React.ComponentProps<"div"> & {
label: string;
value: string;
caption?: string;
icon?: React.ReactNode;
filled?: boolean;
}) {
return (
<div
data-slot="stat-tile"
data-filled={filled || undefined}
className={cn(
"flex flex-col gap-3 rounded-lg p-5 shadow-sm",
filled ? "bg-primary text-primary-foreground" : "border border-border bg-card",
className,
)}
{...props}
>
<div className="flex items-start justify-between gap-3">
<p
className={`text-3xs font-medium uppercase tracking-wider tabular-nums ${
filled ? "text-primary-foreground/70" : "text-muted-foreground"
}`}
>
{label}
</p>
{icon && (
<span
aria-hidden="true"
className={`flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4 ${
filled ? "bg-primary-foreground/12 text-primary-foreground" : "bg-primary/10 text-primary-ink"
}`}
>
{icon}
</span>
)}
</div>
<div>
<p
className={`font-heading text-xl font-medium tabular-nums ${
filled ? "text-primary-foreground" : "text-foreground"
}`}
>
{value}
</p>
{caption && (
<p className={`mt-1 text-xs ${filled ? "text-primary-foreground/75" : "text-muted-foreground"}`}>
{caption}
</p>
)}
</div>
</div>
);
}

ListGroup

An inset grouped list for settings and feeds: lines between rows only, the heading outside the surface.

components/satori-ui/skita/list-group.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* An inset grouped list.
*
* A titled group of rows on one rounded surface, with lines *between* rows
* only, never above the first or below the last: that detail is what
* separates it from a stack of bordered divs. The heading sits outside the
* surface, in uppercase at the 3xs step, because it labels the group rather
* than living in it.
*
* Classes on elements that carry `text-2xs` / `text-3xs` are plain strings,
* not `cn()`, so an unconfigured tailwind-merge cannot drop them.
*/
export function ListGroup({
title,
footnote,
className,
children,
...props
}: React.ComponentProps<"section"> & {
title?: string;
footnote?: string;
}) {
return (
<section className={cn("flex flex-col gap-2", className)} {...props}>
{title && (
<h3 className="px-1 text-3xs font-medium uppercase tracking-wider tabular-nums text-muted-foreground">
{title}
</h3>
)}
{/* `grow`, not `flex-1`: it fills a stretched grid row, so two groups
side by side share a bottom edge, but keeps its own height when
there is nothing to fill. */}
<div className="grow overflow-hidden rounded-lg border border-border bg-card">{children}</div>
{footnote && <p className="px-4 text-2xs text-muted-foreground">{footnote}</p>}
</section>
);
}
/**
* One row: leading glyph, stacked text, trailing value or control.
*
* Rows are 12px tall in padding rather than Apple's 18px. Density is
* balanced, not airy: a settings screen still has to scan in one screenful.
*/
export function ListRow({
leading,
inset,
title,
description,
trailing,
className,
children,
...props
}: Omit<React.ComponentProps<"div">, "title"> & {
leading?: React.ReactNode;
/**
* Reserve the leading column without drawing anything in it. Set it on the
* rows of a group where some other row has a `leading` glyph, or their text
* starts 44px further left and the group gets a ragged edge.
*/
inset?: boolean;
title: React.ReactNode;
description?: React.ReactNode;
trailing?: React.ReactNode;
}) {
return (
<div
data-slot="list-row"
className={cn(
// The line lives on the row and is dropped on the last one, so a
// group never ends on a stray hairline.
"flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0",
className,
)}
{...props}
>
{leading ? (
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary-ink [&_svg]:size-4">
{leading}
</span>
) : inset ? (
<span aria-hidden="true" className="size-8 shrink-0" />
) : null}
<div className="min-w-0 flex-1">
<div className="text-xs/relaxed font-medium text-foreground">{title}</div>
{description && <div className="mt-0.5 text-xs text-muted-foreground">{description}</div>}
{children}
</div>
{trailing && <div className="shrink-0">{trailing}</div>}
</div>
);
}

CascadeLoader

Four squares and a travelling pulse, for work measured in minutes. Paints in currentColor.

components/satori-ui/skita/cascade-loader.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Delay per square, in cycle order, NOT in DOM order.
*
* The grid fills top-left, top-right, bottom-left, bottom-right, so these run
* the pulse clockwise: TL, TR, BR, BL. Sorting them to 0 / 0.2 / 0.4 / 0.6
* would draw a Z, which looks like the obvious fix and is not one.
*/
const DELAYS = ["0s", "0.2s", "0.6s", "0.4s"];
/**
* A four-square cascade, for work that takes minutes rather than moments.
*
* A spinner says "wait" and turns at the same rate whether a request is one
* second or four minutes from returning. This says "something is
* progressing": the pulse travels, so a glance shows the page is alive
* without implying it is nearly done. Use a spinner inside buttons; use this
* where an agent, an import or a render is running.
*
* Squares paint in `currentColor`, so it takes the colour of whatever it sits
* in. Size it with one class: cells are fractions and the gap a percentage.
* The keyframes (`ds-skita-cascade`) live in skita.css.
*/
export function CascadeLoader({
className,
label = "Loading",
...props
}: React.ComponentProps<"div"> & { label?: string }) {
return (
<div
role="status"
aria-label={label}
className={cn("grid size-[23px] grid-cols-2 grid-rows-2 gap-[13%]", className)}
{...props}
>
{DELAYS.map((delay) => (
<span
key={delay}
className="rounded-[2px] bg-current animate-[ds-skita-cascade_1.6s_ease-in-out_infinite] motion-reduce:animate-none motion-reduce:opacity-40"
style={{ animationDelay: delay }}
/>
))}
</div>
);
}

PageHeader

Every screen opens with a title in the heading face, a subtitle and its actions on the same row.

components/satori-ui/skita/page-header.tsx
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Every screen opens with a title and a subtitle.
*
* Page-level actions sit on the title row, never in the top bar: an action
* belongs next to the thing it acts on. The title is one of the few places
* Skita switches to the heading face. Titles are sentence case.
*/
export function PageHeader({
title,
subtitle,
actions,
badge,
className,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
/** Beside the title: a Beta tag, not a status. */
badge?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex flex-wrap items-end justify-between gap-x-6 gap-y-3", className)}>
<div className="min-w-0">
<h1 className="flex items-center gap-2.5 font-heading text-lg font-medium text-foreground">
{title}
{badge}
</h1>
{subtitle && (
<p className="mt-1 max-w-[68ch] text-xs/relaxed text-muted-foreground">{subtitle}</p>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
06

Stylesheet

The whole system is this one file. Read it top to bottom: the vocabulary, the dark theme, the palettes, the Tailwind theme, the utilities, and then the cosmetic layer over each primitive.

styles/skita.css
/* =========================================================================
SKITA — a Satori UI design system
=========================================================================
A dense, monospaced workspace look: zinc neutrals, one green, Geist Mono
as the body face and Instrument Sans for headings. Hairline rings
instead of borders, a neutral focus ring, tinted (never solid) status
colour, and menus that open instantly.
HOW IT IS LAYERED
The foundation is plain shadcn/ui. Nothing in components/ui is edited.
This file does four things on top of it:
1. Defines the Skita vocabulary as `--ds-*` custom properties, scoped
to `.ds-skita` so several systems can live on one page.
2. Bridges that vocabulary onto the shadcn tokens (`--primary`,
`--border`, `--radius` …). Skita has no second semantic layer:
product code reads the shadcn names (bg-primary,
text-muted-foreground) plus one addition, text-primary-ink.
3. Sets the scale. Skita is drawn 10% larger than Tailwind's defaults
on desktop and with larger small type on phones. That is done by
re-pointing Tailwind's own `--spacing` and `--text-*` variables
inside the scope, so utilities keep working and nothing is
overridden.
4. Restyles the details tokens cannot express (the tinted destructive
button, the 1px press, the hairline ring, the small switch, instant
menus) by `data-slot`.
SCOPE
Put `ds-skita` on <html> to theme a whole app. To theme a subtree, put
it on the subtree AND on anything that portals out of it
(DialogContent, SelectContent, DropdownMenuContent, TooltipContent):
portals render on <body>, outside the scope.
Every scope block is also written as `html.ds-skita`. This file has to
be imported at the top of globals.css, so the project's own `:root` and
`.dark` token blocks come after it; the extra type selector is what lets
the system win on <html> instead of losing on source order.
WHAT THE SKIN OWNS
The cosmetic layer (section 07) sets colour, border, radius, shadow,
type and motion on each primitive. It leaves layout alone (padding,
gap, width), so a className like `gap-0 pt-0` on a Card still works.
Density comes from the size props: Skita's everyday button is
`size="sm"`.
FONTS
Geist Mono for everything that is not a heading, Instrument Sans for
headings. Expose them as --font-geist-mono / --font-instrument-sans
(next/font does this), or make "Geist Mono" and "Instrument Sans"
available by name (@fontsource-variable works).
========================================================================= */
/* -------------------------------------------------------------------------
01 VOCABULARY — LIGHT
Zinc for everything, green for the one action and for "done". Status
hues appear only as tints behind words.
------------------------------------------------------------------------- */
html.ds-skita,
.ds-skita {
/* Fonts */
--ds-font-mono:
var(--font-geist-mono, "Geist Mono Variable"), "Geist Mono", ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
--ds-font-heading:
var(--font-instrument-sans, "Instrument Sans Variable"), "Instrument Sans", ui-sans-serif,
system-ui, sans-serif;
/* Ramps the semantic layer points at (Tailwind's zinc and green) */
--ds-zinc-50: oklch(0.985 0 0);
--ds-zinc-100: oklch(0.967 0.001 286.375);
--ds-zinc-200: oklch(0.92 0.004 286.32);
--ds-zinc-400: oklch(0.705 0.015 286.067);
--ds-zinc-500: oklch(0.552 0.016 285.938);
--ds-zinc-800: oklch(0.274 0.006 286.033);
--ds-zinc-900: oklch(0.21 0.006 285.885);
--ds-zinc-950: oklch(0.141 0.005 285.823);
--ds-green-50: oklch(0.982 0.018 155.826);
--ds-green-700: oklch(0.527 0.154 150.069);
--ds-green-800: oklch(0.448 0.119 151.328);
/* The green pair. --ds-primary is the PAINT (buttons, checked controls,
the filled tile). --ds-primary-ink is the TYPE colour (links, the
active nav row, badges). They are the same in light mode and split in
dark, where the fill is too dark to read as text. */
--ds-primary: var(--ds-green-700);
--ds-primary-hover: color-mix(in oklab, var(--ds-primary) 80%, transparent);
--ds-on-primary: var(--ds-green-50);
--ds-primary-ink: var(--ds-primary);
--ds-primary-tint: color-mix(in oklab, var(--ds-primary) 10%, transparent);
/* Surfaces. The canvas is the sidebar's ground; the content panel sits
on it as a white, hairline-ringed sheet. */
--ds-bg-canvas: var(--ds-zinc-50);
--ds-bg-panel: oklch(1 0 0);
--ds-bg-muted: var(--ds-zinc-100);
--ds-bg-raised: oklch(1 0 0);
--ds-text-primary: var(--ds-zinc-950);
--ds-text-secondary: oklch(0.21 0.006 285.885);
--ds-text-muted: var(--ds-zinc-500);
/* Lines. Cards and floating surfaces take a ring of the text colour at
10%, not a border colour, so they read the same on any ground. */
--ds-border: var(--ds-zinc-200);
--ds-input: var(--ds-zinc-200);
--ds-hairline: color-mix(in oklab, var(--ds-text-primary) 10%, transparent);
--ds-highlight: color-mix(in oklab, var(--ds-text-primary) 10%, transparent);
/* Focus is neutral: a zinc edge plus a 2px ring at 30%. Green is never
spent on focus. */
--ds-ring: var(--ds-zinc-400);
--ds-focus-ring: 0 0 0 2px color-mix(in oklab, var(--ds-ring) 30%, transparent);
--ds-focus-ring-danger: 0 0 0 2px color-mix(in oklab, var(--ds-danger) 20%, transparent);
/* Status: tints behind words. `-edge` is the badge outline; most are
transparent, info keeps a line so it does not read as a disabled grey. */
--ds-danger: oklch(0.577 0.245 27.325);
--ds-success: var(--ds-primary);
--ds-success-tint: color-mix(in oklab, var(--ds-primary) 15%, transparent);
--ds-success-on-tint: var(--ds-primary-ink);
--ds-success-edge: transparent;
--ds-warning: oklch(0.769 0.188 70.08);
--ds-warning-tint: color-mix(in oklab, var(--ds-warning) 15%, transparent);
--ds-warning-on-tint: oklch(0.555 0.163 48.998);
--ds-warning-edge: transparent;
--ds-info: oklch(0.685 0.169 237.323);
--ds-info-tint: color-mix(in oklab, var(--ds-info) 10%, transparent);
--ds-info-on-tint: oklch(0.443 0.11 240.79);
--ds-info-edge: color-mix(in oklab, oklch(0.588 0.158 241.966) 40%, transparent);
--ds-danger-tint: color-mix(in oklab, var(--ds-danger) 10%, transparent);
--ds-danger-on-tint: var(--ds-danger);
--ds-danger-edge: transparent;
/* Shape: one radius, and the rest are fractions of it. */
--ds-radius: 0.625rem;
--ds-radius-xs: 2px;
--ds-radius-box: 4px;
--ds-radius-sm: calc(var(--ds-radius) * 0.6);
--ds-radius-md: calc(var(--ds-radius) * 0.8);
--ds-radius-lg: var(--ds-radius);
--ds-radius-xl: calc(var(--ds-radius) * 1.4);
/* Elevation is Tailwind's own shadow-xs / sm / md. A surface takes a
ring or a shadow, rarely both. */
--ds-elev-ring: 0 0 0 1px var(--ds-hairline);
--ds-elev-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--ds-elev-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--ds-elev-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--ds-scrim: rgb(0 0 0 / 0.8);
/* Motion: short and plain. 100ms in for overlays, no springs. */
--ds-ease: cubic-bezier(0, 0, 0.2, 1);
--ds-dur-fast: 100ms;
--ds-dur-hover: 150ms;
--ds-dur-slide: 200ms;
/* ---- The shadcn bridge -------------------------------------------------
Every token the foundation reads, mapped onto the vocabulary above.
This is what re-skins components/ui without editing a single file. */
--background: var(--ds-bg-panel);
--foreground: var(--ds-text-primary);
--card: var(--ds-bg-panel);
--card-foreground: var(--ds-text-primary);
--popover: var(--ds-bg-raised);
--popover-foreground: var(--ds-text-primary);
--primary: var(--ds-primary);
--primary-foreground: var(--ds-on-primary);
--primary-ink: var(--ds-primary-ink);
--secondary: var(--ds-bg-muted);
--secondary-foreground: var(--ds-text-secondary);
--muted: var(--ds-bg-muted);
--muted-foreground: var(--ds-text-muted);
--accent: var(--ds-bg-muted);
--accent-foreground: var(--ds-text-secondary);
--destructive: var(--ds-danger);
--border: var(--ds-border);
--input: var(--ds-input);
--ring: var(--ds-ring);
--radius: var(--ds-radius);
--chart-1: var(--ds-zinc-200);
--chart-2: var(--ds-zinc-500);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: var(--ds-zinc-800);
--sidebar: var(--ds-bg-canvas);
--sidebar-foreground: var(--ds-text-primary);
--sidebar-primary: oklch(0.627 0.194 149.214);
--sidebar-primary-foreground: var(--ds-on-primary);
--sidebar-accent: var(--ds-bg-muted);
--sidebar-accent-foreground: var(--ds-text-secondary);
--sidebar-border: var(--ds-border);
--sidebar-ring: var(--ds-ring);
/* ---- Scale (phones) ----------------------------------------------------
Small type starts large enough to read on a phone. Section 03 brings it
back to the compact desktop scale from 40rem up. */
--text-xs: 0.875rem;
--text-2xs: 0.8125rem;
--text-3xs: 0.75rem;
/* ---- Base typography on the scope ---------------------------------- */
font-family: var(--ds-font-mono);
font-size: var(--text-xs);
line-height: 1.625;
color: var(--ds-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* -------------------------------------------------------------------------
02 VOCABULARY — DARK
The sidebar steps UP from the panel instead of down. The green fill
darkens and the green ink lightens, so each stays legible in its role.
Honours the host's `.dark`.
------------------------------------------------------------------------- */
html.ds-skita:where(.dark, .dark *):not(:where(.dark .light, .dark .light *)),
.ds-skita:where(.dark, .dark *):not(:where(.dark .light, .dark .light *)) {
--ds-primary: var(--ds-green-800);
--ds-primary-ink: oklch(0.8 0.16 152);
--ds-primary-tint: color-mix(in oklab, var(--ds-green-700) 18%, transparent);
--ds-bg-canvas: var(--ds-zinc-900);
--ds-bg-panel: var(--ds-zinc-950);
--ds-bg-muted: var(--ds-zinc-800);
--ds-bg-raised: var(--ds-zinc-900);
--ds-text-primary: var(--ds-zinc-50);
--ds-text-secondary: var(--ds-zinc-50);
--ds-text-muted: var(--ds-zinc-400);
--ds-border: oklch(1 0 0 / 10%);
--ds-input: oklch(1 0 0 / 15%);
--ds-ring: var(--ds-zinc-500);
--ds-danger: oklch(0.704 0.191 22.216);
--ds-success-tint: color-mix(in oklab, var(--ds-green-700) 22%, transparent);
--ds-warning-on-tint: oklch(0.828 0.189 84.429);
--ds-info-on-tint: oklch(0.828 0.111 230.318);
--ds-info-edge: color-mix(in oklab, oklch(0.746 0.16 232.661) 40%, transparent);
--ds-danger-tint: color-mix(in oklab, var(--ds-danger) 20%, transparent);
--sidebar-primary: oklch(0.723 0.219 149.579);
}
/* -------------------------------------------------------------------------
03 SCALE — DESKTOP
Skita's own app sets the root to 110% from 40rem up because the stock
scale read small. A scoped stylesheet cannot touch the root, so it
scales Tailwind's spacing, type and radius variables by the same 10%
instead. Utilities still read those variables, so `h-7`, `px-3` and
`text-xs` all land where they do in Skita, and a className still wins.
------------------------------------------------------------------------- */
@media (width >= 40rem) {
html.ds-skita,
.ds-skita {
--spacing: 0.275rem;
--ds-radius: 0.6875rem;
--text-3xs: 0.6875rem;
--text-2xs: 0.75625rem;
--text-xs: 0.825rem;
--text-sm: 0.9625rem;
--text-base: 1.1rem;
--text-lg: 1.2375rem;
--text-xl: 1.375rem;
--text-2xl: 1.65rem;
}
}
/* -------------------------------------------------------------------------
04 TAILWIND THEME
Two type steps below `xs`, the heading face, and green as text. Every
name here either exists only in Skita or is defined exactly as other
Satori UI systems define it, so two systems can share a build.
------------------------------------------------------------------------- */
@theme inline {
--font-heading: var(--ds-font-heading);
--color-primary-ink: var(--primary-ink);
}
@theme {
--text-2xs: 0.6875rem;
--text-2xs--line-height: calc(1 / 0.6875);
--text-3xs: 0.625rem;
--text-3xs--line-height: calc(0.875 / 0.625);
}
/* -------------------------------------------------------------------------
05 UTILITIES
------------------------------------------------------------------------- */
/* Status tints for the shadcn Badge: `<Badge variant="outline"
className="tint-success">`. Defined identically in every Satori UI
system (each sets its own --ds-* values), so markup moves between them.
The state is always named in words: hue is the second signal. */
@utility tint-success {
background-color: var(--ds-success-tint);
color: var(--ds-success-on-tint);
border-color: var(--ds-success-edge);
}
@utility tint-info {
background-color: var(--ds-info-tint);
color: var(--ds-info-on-tint);
border-color: var(--ds-info-edge);
}
@utility tint-warning {
background-color: var(--ds-warning-tint);
color: var(--ds-warning-on-tint);
border-color: var(--ds-warning-edge);
}
@utility tint-danger {
background-color: var(--ds-danger-tint);
color: var(--ds-danger-on-tint);
border-color: var(--ds-danger-edge);
}
/* The cascade loader's pulse (extras/cascade-loader): each square spends
most of the cycle dim and peaks a third of the way through, so four
squares read as one travelling pulse. */
@keyframes ds-skita-cascade {
0%,
70%,
100% {
opacity: 0.1;
transform: scale(0.85);
}
35% {
opacity: 1;
transform: scale(1);
}
}
/* -------------------------------------------------------------------------
06 BASE BEHAVIOUR INSIDE THE SCOPE
Headings are NOT switched to Instrument Sans here: Skita opts in with
`font-heading` on page titles and big numbers only, and everything else,
card and dialog titles included, stays in the mono body face.
------------------------------------------------------------------------- */
@layer base {
.ds-skita :is(code, pre, samp, kbd) {
font-family: var(--ds-font-mono);
}
}
.ds-skita ::selection {
background-color: var(--ds-primary);
color: var(--ds-on-primary);
}
.ds-skita :is(input, textarea)::placeholder {
color: var(--ds-text-muted);
}
:where(.ds-skita, .ds-skita *) {
outline-color: color-mix(in oklab, var(--ds-ring) 50%, transparent);
}
.ds-skita table {
font-variant-numeric: tabular-nums;
}
/* -------------------------------------------------------------------------
07 COSMETIC LAYER — the shadcn primitives, by data-slot
These rules are unlayered on purpose: they have to beat the utility
classes the primitives already carry. That also means they beat a
className you pass, so they stick to skin (colour, border, radius,
shadow, type, motion) and never set padding, gap or width.
Tailwind v4 moves things with the `translate` property, not
`transform`. Anything here that moves an element sets `translate`, or
it would stack on top of the primitive's own offset.
------------------------------------------------------------------------- */
/* Shared focus: a zinc edge and a 2px ring at 30%. */
:is(
[data-slot="button"],
[data-slot="input"],
[data-slot="textarea"],
[data-slot="select-trigger"],
[data-slot="checkbox"],
[data-slot="radio-group-item"],
[data-slot="switch"],
[data-slot="tabs-trigger"]
):where(.ds-skita, .ds-skita *):focus-visible {
border-color: var(--ds-ring);
outline: none;
box-shadow: var(--ds-focus-ring);
}
/* Button: 0.8× radius, 12px medium mono. Press nudges the button down 1px
rather than shrinking it. Hover fades the fill to 80%. */
[data-slot="button"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
font-size: var(--text-xs);
line-height: 1.625;
font-weight: 500;
box-shadow: none;
transition:
background-color var(--ds-dur-hover) var(--ds-ease),
border-color var(--ds-dur-hover) var(--ds-ease),
color var(--ds-dur-hover) var(--ds-ease),
translate var(--ds-dur-fast) var(--ds-ease);
}
[data-slot="button"]:where(.ds-skita, .ds-skita *):active:not(:disabled, [aria-haspopup]) {
translate: 0 1px;
}
[data-slot="button"][data-variant="default"]:where(.ds-skita, .ds-skita *):hover {
background-color: var(--ds-primary-hover);
}
[data-slot="button"][data-variant="outline"]:where(.ds-skita, .ds-skita *) {
border-color: var(--ds-border);
background-color: transparent;
}
[data-slot="button"][data-variant="outline"]:where(.ds-skita, .ds-skita *):where(.dark *, .dark):not(:where(.dark .light, .dark .light *)) {
background-color: color-mix(in oklab, var(--ds-input) 30%, transparent);
}
[data-slot="button"][data-variant="outline"]:where(.ds-skita, .ds-skita *):is(:hover, [aria-expanded="true"]) {
background-color: color-mix(in oklab, var(--ds-input) 50%, transparent);
color: var(--ds-text-primary);
}
[data-slot="button"][data-variant="secondary"]:where(.ds-skita, .ds-skita *):hover {
background-color: color-mix(in oklch, var(--ds-bg-muted), var(--ds-text-primary) 5%);
}
[data-slot="button"][data-variant="ghost"]:where(.ds-skita, .ds-skita *):is(:hover, [aria-expanded="true"]) {
background-color: var(--ds-bg-muted);
color: var(--ds-text-primary);
}
/* Destructive is a tint, never a solid red: deleting should look
deliberate, not loud. */
[data-slot="button"][data-variant="destructive"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-danger-tint);
color: var(--ds-danger);
}
[data-slot="button"][data-variant="destructive"]:where(.ds-skita, .ds-skita *):hover {
background-color: color-mix(in oklab, var(--ds-danger) 20%, transparent);
}
[data-slot="button"][data-variant="destructive"]:where(.ds-skita, .ds-skita *):focus-visible {
border-color: color-mix(in oklab, var(--ds-danger) 40%, transparent);
box-shadow: var(--ds-focus-ring-danger);
}
[data-slot="button"][data-variant="link"]:where(.ds-skita, .ds-skita *) {
color: var(--ds-primary-ink);
}
/* Badge: a small square-cornered tag at the 3xs step, tinted rather than
solid. The default badge is the green tint; destructive is the red one. */
[data-slot="badge"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-sm);
font-size: var(--text-3xs);
line-height: var(--text-3xs--line-height);
font-weight: 500;
}
[data-slot="badge"][data-variant="default"]:where(.ds-skita, .ds-skita *) {
border-color: transparent;
background-color: var(--ds-primary-tint);
color: var(--ds-primary-ink);
}
[data-slot="badge"][data-variant="outline"]:where(.ds-skita, .ds-skita *):not([class*="tint-"]) {
border-color: var(--ds-border);
color: var(--ds-text-muted);
}
[data-slot="badge"][data-variant="destructive"]:where(.ds-skita, .ds-skita *) {
border-color: transparent;
background-color: var(--ds-danger-tint);
color: var(--ds-danger);
}
/* Card: one radius, a 10% ring in place of a border, no shadow. Titles stay
in the mono body face; only page-level headings switch to Instrument. */
[data-slot="card"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-lg);
border-color: var(--ds-hairline);
box-shadow: none;
font-size: var(--text-xs);
line-height: 1.625;
}
[data-slot="card-title"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-sm);
line-height: 1.4;
font-weight: 500;
}
[data-slot="card-description"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
line-height: 1.625;
}
/* Fields: 0.8× radius, a zinc edge, no fill in light mode. 12px type on
desktop, 16px on phones so iOS never zooms the page on focus. */
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
border-color: var(--ds-border);
background-color: transparent;
box-shadow: none;
font-size: 1rem;
line-height: 1.625;
transition:
border-color var(--ds-dur-hover) var(--ds-ease),
box-shadow var(--ds-dur-hover) var(--ds-ease);
}
@media (width >= 40rem) {
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
}
}
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-skita, .ds-skita *):where(.dark *, .dark):not(:where(.dark .light, .dark .light *)) {
background-color: color-mix(in oklab, var(--ds-input) 30%, transparent);
}
[data-slot="select-trigger"]:where(.ds-skita, .ds-skita *) {
background-color: color-mix(in oklab, var(--ds-input) 20%, transparent);
}
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-skita, .ds-skita *)[aria-invalid="true"] {
border-color: var(--ds-danger);
box-shadow: var(--ds-focus-ring-danger);
}
[data-slot="label"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
font-weight: 500;
}
/* Selection controls: a 4px box and a circle, both filled green when on. */
[data-slot="checkbox"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-box);
border-color: var(--ds-input);
box-shadow: none;
}
[data-slot="checkbox"]:where(.ds-skita, .ds-skita *):is([data-state="checked"], [data-state="indeterminate"]) {
border-color: var(--ds-primary);
background-color: var(--ds-primary);
color: var(--ds-on-primary);
}
[data-slot="radio-group-item"]:where(.ds-skita, .ds-skita *) {
border-color: var(--ds-input);
box-shadow: none;
}
[data-slot="radio-group-item"][data-state="checked"]:where(.ds-skita, .ds-skita *) {
border-color: var(--ds-primary);
background-color: var(--ds-primary);
}
[data-slot="radio-group-item"][data-state="checked"]:where(.ds-skita, .ds-skita *) svg {
fill: var(--ds-on-primary);
color: var(--ds-on-primary);
}
/* Switch: small and flat, 30×18 with a 15px thumb that reaches both ends.
Green when on, no shadow. Travel is the inner width (30 - 2px of
border) minus the thumb, set as `translate` so it replaces the
primitive's own offset. */
[data-slot="switch"]:where(.ds-skita, .ds-skita *) {
height: 18px;
width: 30px;
box-shadow: none;
transition: background-color var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="switch"][data-state="checked"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-primary);
}
[data-slot="switch"][data-size="sm"]:where(.ds-skita, .ds-skita *) {
height: 15px;
width: 26px;
}
[data-slot="switch-thumb"]:where(.ds-skita, .ds-skita *) {
width: 15px;
height: 15px;
translate: 0 0;
transition: translate var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="switch-thumb"][data-state="checked"]:where(.ds-skita, .ds-skita *) {
translate: 13px 0;
}
[data-slot="switch"][data-size="sm"] [data-slot="switch-thumb"]:where(.ds-skita, .ds-skita *) {
width: 12px;
height: 12px;
}
[data-slot="switch"][data-size="sm"] [data-slot="switch-thumb"][data-state="checked"]:where(.ds-skita, .ds-skita *) {
translate: 12px 0;
}
/* Tabs: a muted track and a lifted white trigger with no shadow; in dark
mode the trigger is a faint fill with an edge. */
[data-slot="tabs-list"][data-variant="default"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-lg);
background-color: var(--ds-bg-muted);
}
[data-slot="tabs-trigger"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
font-size: var(--text-xs);
font-weight: 500;
}
[data-slot="tabs-list"][data-variant="default"] [data-slot="tabs-trigger"][data-state="active"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-bg-panel);
color: var(--ds-text-primary);
box-shadow: none;
}
[data-slot="tabs-list"][data-variant="default"] [data-slot="tabs-trigger"][data-state="active"]:where(.ds-skita, .ds-skita *):where(.dark *, .dark):not(:where(.dark .light, .dark .light *)) {
border-color: var(--ds-input);
background-color: color-mix(in oklab, var(--ds-input) 30%, transparent);
}
/* Floating surfaces: one radius, a 10% ring, shadow-md. Menus open and
close instantly: a menu is a tool, not a reveal. Items highlight with
the text colour at 10%, and a destructive item only turns red at rest. */
:is([data-slot="select-content"], [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]):where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-lg);
border-color: var(--ds-hairline);
background-color: var(--ds-bg-raised);
box-shadow: var(--ds-elev-md);
animation: none;
}
:is([data-slot="select-item"], [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"], [data-slot="dropdown-menu-sub-trigger"]):where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
font-size: var(--text-xs);
line-height: 1.625;
}
:is([data-slot="select-item"], [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"], [data-slot="dropdown-menu-sub-trigger"]):where(.ds-skita, .ds-skita *):is(:focus, [data-highlighted], [data-state="open"]) {
background-color: var(--ds-highlight);
color: var(--ds-text-primary);
}
[data-slot="dropdown-menu-item"][data-variant="destructive"]:where(.ds-skita, .ds-skita *):is(:focus, [data-highlighted]) {
background-color: var(--ds-highlight);
color: var(--ds-text-primary);
}
[data-slot="dropdown-menu-item"][data-variant="destructive"]:where(.ds-skita, .ds-skita *):is(:focus, [data-highlighted]) svg {
color: var(--ds-text-primary);
}
:is([data-slot="select-label"], [data-slot="dropdown-menu-label"]):where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
font-weight: 400;
color: var(--ds-text-muted);
}
:is([data-slot="select-separator"], [data-slot="dropdown-menu-separator"]):where(.ds-skita, .ds-skita *) {
background-color: color-mix(in oklab, var(--ds-text-primary) 5%, transparent);
}
[data-slot="dropdown-menu-shortcut"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-3xs);
letter-spacing: 0.1em;
}
/* Tooltip: inverted, the text colour as the fill. The arrow stays. */
[data-slot="tooltip-content"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
background-color: var(--ds-text-primary);
color: var(--ds-bg-panel);
font-size: var(--text-xs);
line-height: 1.5;
}
[data-slot="tooltip-content"]:where(.ds-skita, .ds-skita *) > svg {
background-color: var(--ds-text-primary);
fill: var(--ds-text-primary);
}
[data-slot="tooltip-content"]:where(.ds-skita, .ds-skita *) [data-slot="kbd"] {
background-color: color-mix(in oklab, var(--ds-bg-panel) 20%, transparent);
color: var(--ds-bg-panel);
}
/* Dialog: 1.4× radius, a ring rather than a border, a heavy blurred scrim,
in over 100ms. The title is mono at the sm step. */
[data-slot="dialog-content"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-xl);
border-color: var(--ds-hairline);
background-color: var(--ds-bg-raised);
box-shadow: none;
font-size: var(--text-xs);
line-height: 1.625;
animation-duration: var(--ds-dur-fast);
}
[data-slot="dialog-overlay"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-scrim);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation-duration: var(--ds-dur-fast);
}
[data-slot="dialog-title"]:where(.ds-skita, .ds-skita *) {
font-family: var(--ds-font-mono);
font-size: var(--text-sm);
line-height: 1.4;
font-weight: 500;
}
[data-slot="dialog-description"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
line-height: 1.625;
}
[data-slot="dialog-close"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-sm);
}
/* Table: 12px, headers in the foreground at medium weight (not muted, not
uppercase), rows on a border with a half-muted hover. */
[data-slot="table"]:where(.ds-skita, .ds-skita *) {
font-size: var(--text-xs);
}
[data-slot="table-head"]:where(.ds-skita, .ds-skita *) {
font-weight: 500;
color: var(--ds-text-primary);
}
[data-slot="table-row"]:where(.ds-skita, .ds-skita *) {
border-color: var(--ds-border);
transition: background-color var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="table-row"]:where(.ds-skita, .ds-skita *):hover {
background-color: color-mix(in oklab, var(--ds-bg-muted) 50%, transparent);
}
[data-slot="table-row"][data-state="selected"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-bg-muted);
}
[data-slot="table-footer"]:where(.ds-skita, .ds-skita *) {
background-color: color-mix(in oklab, var(--ds-bg-muted) 50%, transparent);
}
/* Smaller parts */
[data-slot="skeleton"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-md);
background-color: var(--ds-bg-muted);
}
[data-slot="progress"]:where(.ds-skita, .ds-skita *) {
height: 4px;
border-radius: var(--ds-radius-md);
background-color: var(--ds-bg-muted);
}
[data-slot="progress-indicator"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-primary);
}
[data-slot="alert"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-lg);
border-color: var(--ds-border);
background-color: var(--ds-bg-panel);
font-size: var(--text-xs);
line-height: 1.625;
}
[data-slot="alert-title"]:where(.ds-skita, .ds-skita *) {
font-weight: 500;
}
[data-slot="kbd"]:where(.ds-skita, .ds-skita *) {
border-radius: var(--ds-radius-xs);
background-color: var(--ds-bg-muted);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.625rem;
font-weight: 500;
color: var(--ds-text-muted);
}
[data-slot="avatar-fallback"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-bg-muted);
color: var(--ds-text-muted);
}
[data-slot="separator"]:where(.ds-skita, .ds-skita *) {
background-color: var(--ds-border);
}
/* -------------------------------------------------------------------------
08 BLOCK CONTRACT VALUES
What the shared ds-* utilities in section 09 read, said in Skita's
vocabulary. A block written against the contract becomes a Skita screen:
a white panel inset on the zinc canvas, mono everywhere, Instrument Sans
for titles and numbers, one green tile.
------------------------------------------------------------------------- */
html.ds-skita,
.ds-skita {
--ds-corner: round;
--ds-ink: var(--ds-primary-ink);
--ds-ink-tint: var(--ds-primary-tint);
--ds-emphasis: var(--ds-primary);
--ds-on-emphasis: var(--ds-on-primary);
--ds-emphasis-shadow: var(--ds-elev-sm);
--ds-quiet-fill: var(--ds-bg-muted);
/* Shell: the sidebar is the canvas; content is a ringed sheet inset on it. */
--ds-shell-canvas: var(--ds-bg-canvas);
--ds-shell-panel: var(--ds-bg-panel);
--ds-shell-edge: var(--ds-border);
--ds-shell-radius: var(--ds-radius-xl);
--ds-shell-inset: calc(var(--spacing) * 2);
--ds-panel-radius: var(--ds-radius-lg);
--ds-panel-ring: 0 0 0 1px var(--ds-border), var(--ds-elev-xs);
--ds-sidebar-bg: transparent;
--ds-sidebar-blur: none;
--ds-sidebar-edge: transparent;
--ds-topbar-bg: transparent;
--ds-topbar-blur: none;
--ds-nav-active-bg: var(--ds-primary-tint);
--ds-nav-active-edge: transparent;
--ds-nav-active-fg: var(--ds-primary-ink);
/* Shape */
--ds-r-control: var(--ds-radius-md);
--ds-r-surface: var(--ds-radius-lg);
--ds-surface-edge: var(--ds-border);
--ds-surface-shadow: var(--ds-elev-xs);
/* Type: Instrument Sans for titles and numbers, mono for the rest. */
--ds-text-size: var(--text-xs);
--ds-text-leading: 1.625;
--ds-small-size: var(--text-xs);
--ds-small-leading: 1.5;
--ds-nav-size: var(--text-2xs);
--ds-title-font: var(--ds-font-heading);
--ds-title-size: var(--text-lg);
--ds-title-leading: 1.4;
--ds-title-tracking: 0;
--ds-title-weight: 500;
--ds-subtitle-size: var(--text-xs);
--ds-subtitle-leading: 1.625;
--ds-stat-font: var(--ds-font-heading);
--ds-stat-size: var(--text-xl);
--ds-stat-leading: 1.3;
--ds-stat-tracking: 0;
--ds-stat-weight: 500;
--ds-label-font: var(--ds-font-mono);
--ds-label-size: var(--text-3xs);
--ds-label-leading: 1.4;
--ds-label-tracking: 0.05em;
--ds-label-weight: 500;
--ds-label-case: uppercase;
--ds-label-color: var(--ds-text-muted);
--ds-meta-font: var(--ds-font-mono);
--ds-meta-size: var(--text-2xs);
--ds-meta-tracking: 0;
--ds-meta-color: var(--ds-text-muted);
}
/* <block-contract> --------------------------------------------------------
09 BLOCK CONTRACT (shared — identical in every Satori UI system)
Blocks (components/satori-ui/blocks) render in whichever system scopes
them. They read shadcn's tokens, the tint-* utilities and the ds-*
utilities below, never a system's own names. Each utility reads only
--ds-* variables, with a shadcn fallback, and each system sets those
variables in its own section 08. This section must stay byte for byte
the same in every system file: `node design-systems/check-contract.mjs`
fails if two copies drift.
The utilities set skin only. Layout (flex, padding, gaps, widths) stays
in the block's markup, so a className still wins. The one exception is
ds-shell-panel's margin: whether the content panel is inset on the
canvas is part of a system's look, not of the block.
------------------------------------------------------------------------- */
/* App frame: the outer edge and the ground the sidebar sits on. */
@utility ds-shell {
border: 1px solid var(--ds-shell-edge, var(--border));
border-radius: var(--ds-shell-radius, var(--radius));
corner-shape: var(--ds-corner, round);
background-color: var(--ds-shell-canvas, var(--background));
color: var(--foreground);
}
@utility ds-shell-sidebar {
background-color: var(--ds-sidebar-bg, transparent);
backdrop-filter: var(--ds-sidebar-blur, none);
-webkit-backdrop-filter: var(--ds-sidebar-blur, none);
border-right: 1px solid var(--ds-sidebar-edge, transparent);
}
@utility ds-shell-panel {
margin: var(--ds-shell-inset, 0) var(--ds-shell-inset, 0) var(--ds-shell-inset, 0) 0;
border-radius: var(--ds-panel-radius, 0);
corner-shape: var(--ds-corner, round);
background-color: var(--ds-shell-panel, var(--background));
box-shadow: var(--ds-panel-ring, none);
}
@utility ds-shell-topbar {
background-color: var(--ds-topbar-bg, transparent);
backdrop-filter: var(--ds-topbar-blur, none);
-webkit-backdrop-filter: var(--ds-topbar-blur, none);
border-bottom: 1px solid var(--border);
}
/* Navigation rows. The current page is marked with aria-current="page". */
@utility ds-nav-item {
border: 1px solid transparent;
border-radius: var(--ds-r-control, calc(var(--radius) - 2px));
corner-shape: var(--ds-corner, round);
color: var(--muted-foreground);
font-size: var(--ds-nav-size, 0.875rem);
font-weight: 500;
transition:
background-color var(--ds-dur-hover, 150ms) ease,
color var(--ds-dur-hover, 150ms) ease;
&:hover {
background-color: var(--ds-quiet-fill, var(--accent));
color: var(--foreground);
}
&[aria-current="page"] {
background-color: var(--ds-nav-active-bg, var(--accent));
border-color: var(--ds-nav-active-edge, transparent);
color: var(--ds-nav-active-fg, var(--foreground));
}
}
/* The product's mark: the one place the emphasis colour appears in chrome. */
@utility ds-mark {
border-radius: var(--ds-r-control, calc(var(--radius) - 2px));
corner-shape: var(--ds-corner, round);
background-color: var(--ds-emphasis, var(--primary));
color: var(--ds-on-emphasis, var(--primary-foreground));
font-family: var(--ds-title-font, inherit);
font-weight: 600;
}
/* Type. The roles a block needs that shadcn has no name for. Blocks set
copy with ds-text and ds-small rather than text-sm / text-xs, because a
system may re-point Tailwind's steps (Skita draws at 110%). */
@utility ds-text {
font-size: var(--ds-text-size, 0.875rem);
line-height: var(--ds-text-leading, 1.5);
}
@utility ds-small {
font-size: var(--ds-small-size, 0.8125rem);
line-height: var(--ds-small-leading, 1.45);
}
@utility ds-title {
font-family: var(--ds-title-font, inherit);
font-size: var(--ds-title-size, 1.5rem);
line-height: var(--ds-title-leading, 1.25);
letter-spacing: var(--ds-title-tracking, -0.01em);
font-weight: var(--ds-title-weight, 600);
text-transform: var(--ds-title-case, none);
}
@utility ds-subtitle {
font-size: var(--ds-subtitle-size, 0.875rem);
line-height: var(--ds-subtitle-leading, 1.5);
color: var(--muted-foreground);
}
@utility ds-stat {
font-family: var(--ds-stat-font, var(--ds-title-font, inherit));
font-size: var(--ds-stat-size, 1.875rem);
line-height: var(--ds-stat-leading, 1.2);
letter-spacing: var(--ds-stat-tracking, -0.02em);
font-weight: var(--ds-stat-weight, 600);
font-variant-numeric: tabular-nums;
}
/* Data about the content: KPI labels, group headings, column captions. */
@utility ds-label {
font-family: var(--ds-label-font, inherit);
font-size: var(--ds-label-size, 0.6875rem);
line-height: var(--ds-label-leading, 1.3);
letter-spacing: var(--ds-label-tracking, 0.06em);
font-weight: var(--ds-label-weight, 500);
text-transform: var(--ds-label-case, uppercase);
font-variant-numeric: tabular-nums;
color: var(--ds-label-color, var(--muted-foreground));
}
/* A small value: timestamps, IDs, amounts, lengths. */
@utility ds-meta {
font-family: var(--ds-meta-font, inherit);
font-size: var(--ds-meta-size, 0.75rem);
letter-spacing: var(--ds-meta-tracking, 0);
font-variant-numeric: tabular-nums;
color: var(--ds-meta-color, var(--muted-foreground));
}
@utility ds-link {
color: var(--ds-ink, var(--primary));
font-weight: 500;
text-underline-offset: 3px;
&:hover {
text-decoration-line: underline;
}
}
@utility ds-ink {
color: var(--ds-ink, var(--primary));
}
/* Surfaces a block draws itself (a KPI tile, a plan, an empty state). */
@utility ds-tile {
border: 1px solid var(--ds-surface-edge, var(--border));
border-radius: var(--ds-r-surface, var(--radius));
corner-shape: var(--ds-corner, round);
background-color: var(--card);
color: var(--card-foreground);
box-shadow: var(--ds-surface-shadow, none);
}
/* The one emphasised surface a view gets. Labels and meta inside it take
the label colour, so ds-label and ds-meta read on the fill. */
@utility ds-tile-emphasis {
border: 1px solid transparent;
border-radius: var(--ds-r-surface, var(--radius));
corner-shape: var(--ds-corner, round);
background-color: var(--ds-emphasis, var(--primary));
color: var(--ds-on-emphasis, var(--primary-foreground));
box-shadow: var(--ds-emphasis-shadow, none);
--ds-label-color: currentColor;
--ds-meta-color: currentColor;
--muted-foreground: color-mix(in oklab, currentColor 75%, transparent);
}
/* An icon in a wash of the action colour. */
@utility ds-well {
border-radius: var(--ds-r-control, calc(var(--radius) - 2px));
corner-shape: var(--ds-corner, round);
background-color: var(--ds-ink-tint, var(--muted));
color: var(--ds-ink, var(--primary));
}
/* A row or card that highlights on hover with the system's quiet fill. */
@utility ds-hover {
transition: background-color var(--ds-dur-hover, 150ms) ease;
&:hover {
background-color: var(--ds-quiet-fill, var(--accent));
}
}
/* </block-contract> ------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
.ds-skita *,
.ds-skita *::before,
.ds-skita *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}