Folio

new

For reports, research and long reads.

Folio looks like a printed report: off-white paper, black text, serif headings and a red-orange accent. It suits products where people read closely, such as research, finance and publishing.

21 primitives2 extras.ds-folio

A whole page

The foundation plus the two Folio extras, composed into a quarterly report: masthead, figures, a ruled table and a note.

Quarterly report · Q3 2026

A ninth quarter of growth

Subscriptions carried the quarter. Advertising was flat, costs grew more slowly than revenue, and the margin widened for a third quarter in a row.

Revenue

$48.2m

+14%on Q3 last year

Subscribers

212k

+9knet, after churn

Margin

31%

+2.1 ptsthird rise in a row

Table 1 · Revenue by region

RegionRevenueAgainst plan
North America$21.4mAhead
Europe$14.9mOn plan
Asia Pacific$8.7mBehind

Figures unaudited. Change is on the same quarter last year.

What changed
The editor's note

The print edition, launched in July, paid for itself in its second month and brought 3,100 new subscribers.

01

Principles

01

Ink does the work

Text, rules and the primary button are all the same near-black. A page reads as one printed sheet, not a set of coloured panels.

02

One vermilion

Vermilion marks what is live (links, focus, checked controls, progress) and paints the one figure a page is about. Nothing else is red unless it is an error.

03

Serif names, sans reads

Instrument Serif italic is for what a page names and the numbers it is about. Everything you read at length or act on is Geist.

04

Rules, not shadows

Sheets are separated by hairlines. A full ink rule sits under a masthead and a table head. Only menus and dialogs, which float, cast a shadow.

05

Nearly square

Two pixels on controls and cards, three on dialogs, none on tags. The switch is square too. Only avatars are round.

06

Printed focus

Focus is a 2px vermilion outline with a 2px offset, the same on every control, never a glow and never removed. Motion is a 120ms fade.

02

Installation

1

Add the system

pnpm dlx shadcn@latest add @satoriui/folio

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

2

Import the stylesheet

app/globals.css
@import "tailwindcss";
@import "tw-animate-css";
@import "../styles/folio.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-folio">

Every token lives under .ds-folio. 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, Geist_Mono, Instrument_Serif } from "next/font/google";
const sans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" });
const serif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
style: "italic",
variable: "--font-instrument-serif",
});
<body className={`${sans.variable} ${mono.variable} ${serif.variable}`}>

Instrument Serif ships one weight; load the italic. The stacks also name each family directly, so @fontsource packages work outside Next.js.

03

Foundations

Colour

Off-white paper, black ink and a red-orange accent. Status colours appear as soft tints behind words, and the green is muted so it never competes.

Ink and vermilion

  • blackText, rules and the primary button
  • redChecked controls, progress, focus, the emphasised figure
  • red-inkVermilion as text: links and kickers
  • red-tintSelected rows

Status

  • successPublished, ahead of plan. Shown as a tint
  • warningEmbargoed, behind plan
  • infoIn review
  • dangerErrors, retractions and destructive actions

Paper

  • canvasThe page
  • surfaceCards and fields: a lighter sheet laid on the page
  • mutedHovers, skeletons, quiet buttons

Text and rules

  • foregroundBody and headings
  • secondaryStandfirsts and long captions
  • mutedLabels, dates, descriptions
  • ruleHairlines between rows and around sheets

Type

Three faces with separate jobs: Instrument Serif italic to name, Geist to read, Geist Mono in small caps to label.

The quick brown foxfigure48px · Instrument Serif italic
The quick brown foxpage title38 / 40 · Instrument Serif italic
The quick brown foxcard title23px · Instrument Serif italic
The quick brown foxbody15 / 23 · Geist · 400
The quick brown foxsmall13.5px · Geist · 400
The quick brown foxlabel10.5px · Geist Mono · +0.1em

Display

Instrument Serif

Set in italic for page titles, card titles and headline figures. Opt in elsewhere with font-editorial.

Reading

Geist

Body copy, fields, buttons and table cells, at 15px so long text reads comfortably.

Labels

Geist Mono

Small caps with wide tracking: table heads, kickers, badges and data.

Shape and elevation

Nearly square, and flat. Depth is a hairline rule; only floating surfaces cast a shadow.

Radii

tag0px
control2px
dialog3px

Elevation

rule
float
  • ruleCards and rows: a hairline, no shadow
  • floatMenus, selects and dialogs, the only things that cast a shadow
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

Ink on paper, two-pixel corners. Hover lightens the ink; the outline button fills with ink. Nothing moves, nothing glows.

Badges

Square tags in small-caps mono. Status is a tint behind a word, never a coloured dot alone.

Lead storyAnalysisQ3 2026
PublishedIn reviewEmbargoedRetracted

Fields

A lighter sheet with a hairline edge. Focus is a printed vermilion outline; an error adds a rule down the left and replaces the hint.

Sentence case. Under 70 characters reads in one line.

Use lowercase letters and hyphens, no spaces.

Selection controls

Square boxes, round radios and a square switch, all vermilion when on.

Sections

Edition

Delivery

Tabs

Ruled, not boxed: the track is a hairline and the active tab underlines in vermilion.

Revenue rose 14% on the year, led by subscriptions. Costs grew more slowly, so the margin widened for a third quarter.

Figures and cards

The Figure extra sets numbers like an annual report. Cards are flat sheets with serif italic titles.

Revenue

$48.2m

+14%on the same quarter last year

Subscribers

212k

+9knet, after churn

Margin

31%

+2.1 ptsthe third rise in a row

The quarter in brief
Published November 5 · Four minutes
Subscriptions carried the quarter. Advertising was flat, and the new print edition paid for itself in its second month.

Table

Set like a printed table: small-caps heads over a full ink rule, hairlines between rows, tabular figures.

RegionRevenueChangeAgainst plan
North America$21.4m+16%Ahead
Europe$14.9m+11%On plan
Asia Pacific$8.7m+4%Behind
Latin America$3.2m−2%Review

Dialog, menu and tooltip

The only things that cast a shadow. The dialog has a heavy top rule; menu highlights invert to ink.

05

Extras

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

Masthead

The top of a page: a vermilion kicker, a serif italic title, a standfirst and a full ink rule under it.

components/satori-ui/folio/masthead.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* The top of a Folio page, set like a magazine masthead: a small-caps
* kicker, a serif italic title, a standfirst, and a full ink rule under it.
*
* Actions sit on the title row. The kicker names the section the page
* belongs to ("Reports", "Q3"), never repeats the title.
*/
export function Masthead({
kicker,
title,
standfirst,
actions,
className,
...props
}: Omit<React.ComponentProps<"header">, "title"> & {
kicker?: string;
title: string;
/** One or two sentences under the title. */
standfirst?: string;
actions?: React.ReactNode;
}) {
return (
<header
data-slot="masthead"
className={cn("mb-8 border-b border-[var(--ds-rule-strong)] pb-5", className)}
{...props}
>
{kicker && (
<p className="mb-2 font-mono text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--ds-red-ink)]">
{kicker}
</p>
)}
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<h1 className="font-editorial text-[40px] leading-[1.02] tracking-[-0.01em] italic text-foreground">{title}</h1>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
{standfirst && (
<p className="mt-3 max-w-[60ch] text-[16px] leading-relaxed text-[var(--ds-text-secondary)]">{standfirst}</p>
)}
</header>
);
}

Figure

A headline number set like an annual report: a rule on top, the value in serif italic, the change in words. One emphasised figure per row.

components/satori-ui/folio/figure.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* A figure, the way an annual report sets one: the number large in serif
* italic, a small-caps label above it, a sentence of context below, and a
* rule on top instead of a box.
*
* `change` is shown as text with a sign, never as colour alone. Set
* `emphasis` on at most one figure per row: it takes the vermilion.
*/
export function Figure({
label,
value,
caption,
change,
emphasis = false,
className,
...props
}: React.ComponentProps<"figure"> & {
label: string;
value: string;
caption?: string;
/** A signed change, such as "+12%" or "−0.4 pts". */
change?: string;
emphasis?: boolean;
}) {
return (
<figure
data-slot="figure"
data-emphasis={emphasis || undefined}
className={cn(
"flex flex-col gap-2 border-t-2 pt-3",
emphasis ? "border-[var(--ds-red)]" : "border-[var(--ds-rule-strong)]",
className,
)}
{...props}
>
<figcaption className="font-mono text-[10.5px] font-medium uppercase tracking-[0.1em] text-muted-foreground">
{label}
</figcaption>
<p
className={`font-editorial text-[48px] leading-none tracking-[-0.01em] italic tabular-nums ${
emphasis ? "text-[var(--ds-red-ink)]" : "text-foreground"
}`}
>
{value}
</p>
{(caption || change) && (
<p className="text-[13.5px] leading-snug text-muted-foreground">
{change && <span className="mr-1.5 font-mono text-[12px] text-foreground">{change}</span>}
{caption}
</p>
)}
</figure>
);
}
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/folio.css
/* =========================================================================
FOLIO — a Satori UI design system
=========================================================================
An editorial look for products that publish: reports, research, finance,
long reads. Warm paper, ink-black text and buttons, one vermilion, serif
italic for the things a page names and the numbers it is about, hairline
rules in place of shadows, corners that are nearly square.
HOW IT IS LAYERED
The foundation is plain shadcn/ui. Nothing in components/ui is edited.
This file does three things on top of it:
1. Defines the Folio vocabulary as `--ds-*` custom properties, scoped
to `.ds-folio` so several systems can live on one page.
2. Bridges that vocabulary onto the shadcn tokens (`--primary`,
`--border`, `--radius` …) so every primitive re-skins untouched.
3. Restyles what tokens cannot express (ruled tables, the printed
focus outline, the square switch, small-caps labels) by `data-slot`.
SCOPE
Put `ds-folio` 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-folio`, so the system wins
on <html> over the project's own `:root` and `.dark` blocks, which come
later in globals.css.
WHAT THE SKIN OWNS
The cosmetic layer (section 07) sets colour, border, radius, shadow,
type and motion on each primitive, never padding, gap or width.
FONTS
Instrument Serif (italic) for titles and figures, Geist for reading and
interface, Geist Mono for labels and data. Expose them as
--font-instrument-serif / --font-geist-sans / --font-geist-mono
(next/font does this), or make the families available by name.
========================================================================= */
/* -------------------------------------------------------------------------
01 VOCABULARY — LIGHT
Ink does the work: text, rules and the primary button. Vermilion marks
what is live (links, focus, checked controls, progress) and is the one
emphasised surface a page gets. Paper is warm, never grey.
------------------------------------------------------------------------- */
html.ds-folio,
.ds-folio {
/* Fonts */
--ds-font-display:
var(--font-instrument-serif, "Instrument Serif"), "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--ds-font-sans: var(--font-geist-sans, "Geist"), ui-sans-serif, system-ui, sans-serif;
--ds-font-mono: var(--font-geist-mono, "Geist Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
/* Ramps */
--ds-paper-50: oklch(0.992 0.005 85);
--ds-paper-100: oklch(0.975 0.009 85);
--ds-paper-200: oklch(0.948 0.012 82);
--ds-paper-300: oklch(0.9 0.014 80);
--ds-ink-400: oklch(0.62 0.012 70);
--ds-ink-500: oklch(0.52 0.012 65);
--ds-ink-700: oklch(0.36 0.01 60);
--ds-ink-900: oklch(0.2 0.008 60);
--ds-vermilion-500: oklch(0.6 0.19 33);
--ds-vermilion-600: oklch(0.54 0.19 32);
/* Black ink: the primary button and every rule. */
--ds-black: var(--ds-ink-900);
--ds-black-hover: var(--ds-ink-700);
--ds-on-black: var(--ds-paper-50);
/* Vermilion: the PAINT (--ds-red) and the TYPE (--ds-red-ink), which is a
step darker so links pass AA on paper. */
--ds-red: var(--ds-vermilion-500);
--ds-red-ink: var(--ds-vermilion-600);
--ds-on-red: var(--ds-paper-50);
--ds-red-tint: oklch(0.95 0.03 40);
/* Status, as tints behind words. Success is a muted green so it never
competes with the vermilion. */
--ds-success: oklch(0.55 0.11 150);
--ds-success-tint: color-mix(in oklab, var(--ds-success) 13%, transparent);
--ds-success-on-tint: oklch(0.42 0.09 150);
--ds-success-edge: color-mix(in oklab, var(--ds-success) 35%, transparent);
--ds-info: oklch(0.52 0.1 245);
--ds-info-tint: color-mix(in oklab, var(--ds-info) 12%, transparent);
--ds-info-on-tint: oklch(0.42 0.09 245);
--ds-info-edge: color-mix(in oklab, var(--ds-info) 35%, transparent);
--ds-warning: oklch(0.72 0.14 75);
--ds-warning-tint: color-mix(in oklab, var(--ds-warning) 18%, transparent);
--ds-warning-on-tint: oklch(0.45 0.1 65);
--ds-warning-edge: color-mix(in oklab, var(--ds-warning) 45%, transparent);
--ds-danger: oklch(0.52 0.19 28);
--ds-danger-tint: color-mix(in oklab, var(--ds-danger) 12%, transparent);
--ds-danger-on-tint: oklch(0.45 0.17 28);
--ds-danger-edge: color-mix(in oklab, var(--ds-danger) 35%, transparent);
/* Surfaces: the page is paper; cards are a lighter sheet laid on it. */
--ds-bg-canvas: var(--ds-paper-100);
--ds-bg-surface: var(--ds-paper-50);
--ds-bg-muted: var(--ds-paper-200);
--ds-bg-raised: var(--ds-paper-50);
--ds-text-primary: var(--ds-ink-900);
--ds-text-secondary: var(--ds-ink-700);
--ds-text-muted: var(--ds-ink-500);
/* Rules. A hairline of ink, and a full-strength rule for the line under a
table head or a masthead. */
--ds-rule: color-mix(in oklab, var(--ds-ink-900) 13%, transparent);
--ds-rule-strong: var(--ds-ink-900);
--ds-input: color-mix(in oklab, var(--ds-ink-900) 24%, transparent);
/* Focus is printed, not glowing: a 2px vermilion outline, offset. */
--ds-focus: var(--ds-red);
--ds-scrim: color-mix(in oklab, var(--ds-ink-900) 55%, transparent);
/* Shape: nearly square. Tags are square; nothing is a pill but an avatar. */
--ds-radius-tag: 0px;
--ds-radius: 2px;
--ds-radius-sheet: 3px;
/* Elevation exists only for things that float. */
--ds-elev-float: 0 1px 0 var(--ds-rule), 0 12px 32px -12px rgb(40 30 20 / 0.25);
/* Motion: quick and plain. */
--ds-ease: cubic-bezier(0.2, 0, 0, 1);
--ds-dur-hover: 120ms;
/* ---- The shadcn bridge -------------------------------------------------- */
--background: var(--ds-bg-canvas);
--foreground: var(--ds-text-primary);
--card: var(--ds-bg-surface);
--card-foreground: var(--ds-text-primary);
--popover: var(--ds-bg-raised);
--popover-foreground: var(--ds-text-primary);
--primary: var(--ds-black);
--primary-foreground: var(--ds-on-black);
--secondary: var(--ds-bg-muted);
--secondary-foreground: var(--ds-text-primary);
--muted: var(--ds-bg-muted);
--muted-foreground: var(--ds-text-muted);
--accent: var(--ds-bg-muted);
--accent-foreground: var(--ds-text-primary);
--destructive: var(--ds-danger);
--border: var(--ds-rule);
--input: var(--ds-input);
--ring: var(--ds-red);
--radius: var(--ds-radius);
--chart-1: var(--ds-black);
--chart-2: var(--ds-red);
--chart-3: var(--ds-ink-400);
--chart-4: var(--ds-info);
--chart-5: var(--ds-paper-300);
--sidebar: var(--ds-bg-canvas);
--sidebar-foreground: var(--ds-text-primary);
--sidebar-primary: var(--ds-black);
--sidebar-primary-foreground: var(--ds-on-black);
--sidebar-accent: var(--ds-bg-muted);
--sidebar-accent-foreground: var(--ds-text-primary);
--sidebar-border: var(--ds-rule);
--sidebar-ring: var(--ds-red);
/* ---- Base typography on the scope ------------------------------------ */
font-family: var(--ds-font-sans);
font-size: 15px;
line-height: 1.55;
color: var(--ds-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "ss01", "cv11";
}
/* -------------------------------------------------------------------------
02 VOCABULARY — DARK (the night edition)
Paper and ink swap: the primary button becomes a paper fill with ink
text, rules become paper at low alpha, vermilion lifts to stay legible.
------------------------------------------------------------------------- */
html.ds-folio:where(.dark, .dark *):not(:where(.dark .light, .dark .light *)),
.ds-folio:where(.dark, .dark *):not(:where(.dark .light, .dark .light *)) {
--ds-black: var(--ds-paper-100);
--ds-black-hover: var(--ds-paper-300);
--ds-on-black: var(--ds-ink-900);
--ds-red: oklch(0.66 0.18 35);
--ds-red-ink: oklch(0.74 0.15 38);
--ds-on-red: oklch(0.18 0.01 40);
--ds-red-tint: oklch(0.3 0.07 35);
--ds-success-on-tint: oklch(0.8 0.1 150);
--ds-info-on-tint: oklch(0.8 0.08 245);
--ds-warning-on-tint: oklch(0.85 0.12 80);
--ds-danger: oklch(0.65 0.18 28);
--ds-danger-on-tint: oklch(0.8 0.12 28);
--ds-success-tint: color-mix(in oklab, var(--ds-success) 22%, transparent);
--ds-info-tint: color-mix(in oklab, var(--ds-info) 25%, transparent);
--ds-danger-tint: color-mix(in oklab, var(--ds-danger) 22%, transparent);
--ds-bg-canvas: oklch(0.165 0.008 60);
--ds-bg-surface: oklch(0.2 0.008 60);
--ds-bg-muted: oklch(0.255 0.009 60);
--ds-bg-raised: oklch(0.22 0.008 60);
--ds-text-primary: var(--ds-paper-100);
--ds-text-secondary: var(--ds-paper-300);
--ds-text-muted: oklch(0.7 0.012 75);
--ds-rule: color-mix(in oklab, var(--ds-paper-100) 14%, transparent);
--ds-rule-strong: var(--ds-paper-100);
--ds-input: color-mix(in oklab, var(--ds-paper-100) 26%, transparent);
--ds-scrim: rgb(0 0 0 / 0.6);
--ds-elev-float: 0 0 0 1px var(--ds-rule), 0 16px 40px -12px rgb(0 0 0 / 0.7);
}
/* -------------------------------------------------------------------------
04 TAILWIND THEME
One addition, `font-editorial`: the display face, for anything a page
names or counts. (Not `font-display`: the site already has that class.)
------------------------------------------------------------------------- */
@theme inline {
--font-editorial: var(--ds-font-display);
}
/* -------------------------------------------------------------------------
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);
}
/* -------------------------------------------------------------------------
06 BASE BEHAVIOUR INSIDE THE SCOPE
------------------------------------------------------------------------- */
.ds-folio :is(code, kbd, pre, samp) {
font-family: var(--ds-font-mono);
}
.ds-folio ::selection {
background-color: color-mix(in oklab, var(--ds-red) 22%, transparent);
}
.ds-folio :is(input, textarea)::placeholder {
color: var(--ds-text-muted);
}
.ds-folio table {
font-variant-numeric: tabular-nums lining-nums;
}
/* Focus is a printed outline, never a glow, and never removed. */
:where(.ds-folio, .ds-folio *):focus-visible {
outline: 2px solid var(--ds-focus);
outline-offset: 2px;
box-shadow: none;
}
.ds-folio svg.lucide {
stroke-width: 1.6;
}
/* -------------------------------------------------------------------------
07 COSMETIC LAYER — the shadcn primitives, by data-slot
Unlayered on purpose so it beats the classes the primitives carry. It
sets skin only (colour, border, radius, shadow, type, motion), never
padding, gap or width. Things that move set `translate`, not `transform`.
------------------------------------------------------------------------- */
/* Every focusable primitive: the printed outline, and no ring utility. */
: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-folio, .ds-folio *):focus-visible {
outline: 2px solid var(--ds-focus);
outline-offset: 2px;
box-shadow: none;
}
/* Button: square-ish, ink. Hover lightens the ink; nothing moves. */
[data-slot="button"]:where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius);
font-weight: 500;
letter-spacing: -0.005em;
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);
}
[data-slot="button"][data-variant="default"]:where(.ds-folio, .ds-folio *):hover {
background-color: var(--ds-black-hover);
}
[data-slot="button"][data-variant="outline"]:where(.ds-folio, .ds-folio *) {
border-color: var(--ds-rule-strong);
background-color: transparent;
}
[data-slot="button"][data-variant="outline"]:where(.ds-folio, .ds-folio *):hover {
background-color: var(--ds-black);
color: var(--ds-on-black);
}
[data-slot="button"][data-variant="secondary"]:where(.ds-folio, .ds-folio *):hover,
[data-slot="button"][data-variant="ghost"]:where(.ds-folio, .ds-folio *):hover {
background-color: var(--ds-bg-muted);
color: var(--ds-text-primary);
}
[data-slot="button"][data-variant="destructive"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-danger);
color: var(--ds-paper-50);
}
[data-slot="button"][data-variant="link"]:where(.ds-folio, .ds-folio *) {
color: var(--ds-red-ink);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
/* Badge: a square tag in small caps. */
[data-slot="badge"]:where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius-tag);
font-family: var(--ds-font-mono);
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
}
[data-slot="badge"][data-variant="default"]:where(.ds-folio, .ds-folio *) {
border-color: transparent;
background-color: var(--ds-black);
color: var(--ds-on-black);
}
[data-slot="badge"][data-variant="outline"]:where(.ds-folio, .ds-folio *):not([class*="tint-"]) {
border-color: var(--ds-rule-strong);
color: var(--ds-text-primary);
}
/* Card: a sheet with a hairline edge and no shadow. Titles are serif italic. */
[data-slot="card"]:where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius);
border-color: var(--ds-rule);
box-shadow: none;
}
[data-slot="card-title"]:where(.ds-folio, .ds-folio *) {
font-family: var(--ds-font-display);
font-style: italic;
font-weight: 400;
font-size: 23px;
line-height: 1.15;
letter-spacing: -0.005em;
}
[data-slot="card-description"]:where(.ds-folio, .ds-folio *) {
font-size: 13.5px;
line-height: 1.45;
}
/* Fields: an underline-weight edge on paper. Focus is the outline. */
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius);
border-color: var(--ds-input);
background-color: var(--ds-bg-surface);
box-shadow: none;
font-size: 15px;
transition: border-color var(--ds-dur-hover) var(--ds-ease);
}
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-folio, .ds-folio *):hover {
border-color: color-mix(in oklab, var(--ds-text-primary) 45%, transparent);
}
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-folio, .ds-folio *):focus-visible {
border-color: var(--ds-rule-strong);
}
:is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):where(.ds-folio, .ds-folio *)[aria-invalid="true"] {
border-color: var(--ds-danger);
box-shadow: inset 3px 0 0 var(--ds-danger);
}
[data-slot="label"]:where(.ds-folio, .ds-folio *) {
font-size: 13.5px;
font-weight: 500;
}
/* Selection controls: square box, round radio, vermilion when on. */
[data-slot="checkbox"]:where(.ds-folio, .ds-folio *) {
border-radius: 1px;
border-color: var(--ds-rule-strong);
background-color: var(--ds-bg-surface);
box-shadow: none;
}
[data-slot="checkbox"]:where(.ds-folio, .ds-folio *):is([data-state="checked"], [data-state="indeterminate"]) {
border-color: var(--ds-red);
background-color: var(--ds-red);
color: var(--ds-on-red);
}
[data-slot="radio-group-item"]:where(.ds-folio, .ds-folio *) {
border-color: var(--ds-rule-strong);
background-color: var(--ds-bg-surface);
box-shadow: none;
}
[data-slot="radio-group-item"][data-state="checked"]:where(.ds-folio, .ds-folio *) {
border-color: var(--ds-red);
}
[data-slot="radio-group-item"][data-state="checked"]:where(.ds-folio, .ds-folio *) svg {
fill: var(--ds-red);
color: var(--ds-red);
}
/* Switch: square track and thumb, like a printed toggle. 36×20 with a 14px
thumb inset 2px; travel is 36 - 2·2 border-box - 14 - 2·2 = 14px. */
[data-slot="switch"]:where(.ds-folio, .ds-folio *) {
height: 20px;
width: 36px;
border-radius: 2px;
border: 1px solid var(--ds-rule-strong);
background-color: transparent;
box-shadow: none;
padding: 2px;
transition: background-color var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="switch"][data-state="checked"]:where(.ds-folio, .ds-folio *) {
border-color: var(--ds-red);
background-color: var(--ds-red);
}
[data-slot="switch-thumb"]:where(.ds-folio, .ds-folio *) {
width: 14px;
height: 14px;
border-radius: 1px;
background-color: var(--ds-black);
box-shadow: none;
translate: 0 0;
transition: translate var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="switch-thumb"][data-state="checked"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-on-red);
translate: 16px 0;
}
/* Tabs: ruled, not boxed. The track is a rule underneath; the active tab
is ink on paper with the rule turned vermilion under it. */
[data-slot="tabs-list"][data-variant="default"]:where(.ds-folio, .ds-folio *) {
border-radius: 0;
background-color: transparent;
box-shadow: inset 0 -1px 0 var(--ds-rule);
padding: 0;
}
[data-slot="tabs-trigger"]:where(.ds-folio, .ds-folio *) {
border-radius: 0;
border: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--ds-text-muted);
transition: color var(--ds-dur-hover) var(--ds-ease), box-shadow var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="tabs-trigger"]:where(.ds-folio, .ds-folio *):hover {
color: var(--ds-text-primary);
}
[data-slot="tabs-list"] [data-slot="tabs-trigger"][data-state="active"]:where(.ds-folio, .ds-folio *) {
background-color: transparent;
color: var(--ds-text-primary);
box-shadow: inset 0 -2px 0 var(--ds-red);
}
[data-slot="tabs-list"][data-variant="line"] [data-slot="tabs-trigger"]:where(.ds-folio, .ds-folio *)::after {
background-color: var(--ds-red);
}
/* Floating surfaces: paper sheets with a hairline and a drop. */
:is([data-slot="select-content"], [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]):where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius);
border-color: var(--ds-rule);
background-color: var(--ds-bg-raised);
box-shadow: var(--ds-elev-float);
}
: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-folio, .ds-folio *) {
border-radius: 1px;
font-size: 14px;
}
: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-folio, .ds-folio *):is(:focus, [data-highlighted], [data-state="open"]) {
background-color: var(--ds-black);
color: var(--ds-on-black);
}
:is([data-slot="select-item"], [data-slot="dropdown-menu-item"]):where(.ds-folio, .ds-folio *):is(:focus, [data-highlighted]) svg {
color: var(--ds-on-black);
}
:is([data-slot="select-label"], [data-slot="dropdown-menu-label"]):where(.ds-folio, .ds-folio *) {
font-family: var(--ds-font-mono);
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ds-text-muted);
}
:is([data-slot="select-separator"], [data-slot="dropdown-menu-separator"]):where(.ds-folio, .ds-folio *) {
background-color: var(--ds-rule);
}
/* Tooltip: an ink slip with paper text, square, no arrow. */
[data-slot="tooltip-content"]:where(.ds-folio, .ds-folio *) {
border-radius: 1px;
background-color: var(--ds-black);
color: var(--ds-on-black);
font-size: 12.5px;
}
[data-slot="tooltip-content"]:where(.ds-folio, .ds-folio *) > svg {
display: none;
}
/* Dialog: a sheet with a heavy top rule, serif italic title. */
[data-slot="dialog-content"]:where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius-sheet);
border-color: var(--ds-rule);
border-top: 3px solid var(--ds-rule-strong);
background-color: var(--ds-bg-raised);
box-shadow: var(--ds-elev-float);
}
[data-slot="dialog-overlay"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-scrim);
}
[data-slot="dialog-title"]:where(.ds-folio, .ds-folio *) {
font-family: var(--ds-font-display);
font-style: italic;
font-weight: 400;
font-size: 26px;
line-height: 1.1;
}
[data-slot="dialog-description"]:where(.ds-folio, .ds-folio *) {
font-size: 14px;
}
/* Table: set like a printed table. Small-caps heads over a full ink rule,
hairlines between rows, no hover wash beyond a faint paper step. */
[data-slot="table"]:where(.ds-folio, .ds-folio *) {
font-size: 14px;
}
[data-slot="table-header"] [data-slot="table-row"]:where(.ds-folio, .ds-folio *) {
border-bottom: 1px solid var(--ds-rule-strong);
}
[data-slot="table-head"]:where(.ds-folio, .ds-folio *) {
font-family: var(--ds-font-mono);
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ds-text-primary);
}
[data-slot="table-row"]:where(.ds-folio, .ds-folio *) {
border-color: var(--ds-rule);
transition: background-color var(--ds-dur-hover) var(--ds-ease);
}
[data-slot="table-body"] [data-slot="table-row"]:where(.ds-folio, .ds-folio *):hover {
background-color: color-mix(in oklab, var(--ds-bg-muted) 55%, transparent);
}
[data-slot="table-row"][data-state="selected"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-red-tint);
}
/* Smaller parts */
[data-slot="skeleton"]:where(.ds-folio, .ds-folio *) {
border-radius: 1px;
background-color: var(--ds-bg-muted);
}
[data-slot="progress"]:where(.ds-folio, .ds-folio *) {
height: 4px;
border-radius: 0;
background-color: var(--ds-rule);
}
[data-slot="progress-indicator"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-red);
}
[data-slot="alert"]:where(.ds-folio, .ds-folio *) {
border-radius: var(--ds-radius);
border-color: var(--ds-rule);
border-left: 3px solid var(--ds-rule-strong);
background-color: var(--ds-bg-surface);
font-size: 14px;
}
[data-slot="kbd"]:where(.ds-folio, .ds-folio *) {
border-radius: 1px;
background-color: var(--ds-bg-muted);
font-family: var(--ds-font-mono);
font-size: 11px;
color: var(--ds-text-secondary);
}
[data-slot="avatar-fallback"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-bg-muted);
color: var(--ds-text-secondary);
font-family: var(--ds-font-display);
font-style: italic;
font-size: 14px;
}
[data-slot="separator"]:where(.ds-folio, .ds-folio *) {
background-color: var(--ds-rule);
}
/* -------------------------------------------------------------------------
08 BLOCK CONTRACT VALUES
What the shared ds-* utilities in section 09 read, said in Folio's
vocabulary. A block written against the contract becomes a Folio page:
paper chrome with a ruled edge, serif italic titles and figures,
small-caps mono labels, one vermilion sheet.
------------------------------------------------------------------------- */
html.ds-folio,
.ds-folio {
--ds-corner: round;
--ds-ink: var(--ds-red-ink);
--ds-ink-tint: var(--ds-red-tint);
--ds-emphasis: var(--ds-red);
--ds-on-emphasis: var(--ds-on-red);
--ds-emphasis-shadow: none;
--ds-quiet-fill: var(--ds-bg-muted);
/* Shell: one sheet of paper, the sidebar separated by a rule. */
--ds-shell-canvas: var(--ds-bg-canvas);
--ds-shell-panel: var(--ds-bg-canvas);
--ds-shell-edge: var(--ds-rule-strong);
--ds-shell-radius: var(--ds-radius-sheet);
--ds-shell-inset: 0px;
--ds-panel-radius: 0px;
--ds-panel-ring: none;
--ds-sidebar-bg: transparent;
--ds-sidebar-blur: none;
--ds-sidebar-edge: var(--ds-rule);
--ds-topbar-bg: transparent;
--ds-topbar-blur: none;
--ds-nav-active-bg: var(--ds-black);
--ds-nav-active-edge: transparent;
--ds-nav-active-fg: var(--ds-on-black);
/* Shape */
--ds-r-control: var(--ds-radius);
--ds-r-surface: var(--ds-radius);
--ds-surface-edge: var(--ds-rule);
--ds-surface-shadow: none;
/* Type */
--ds-text-size: 15px;
--ds-text-leading: 1.55;
--ds-small-size: 13.5px;
--ds-small-leading: 1.45;
--ds-nav-size: 14px;
--ds-title-font: var(--ds-font-display);
--ds-title-size: 38px;
--ds-title-leading: 1.05;
--ds-title-tracking: -0.01em;
--ds-title-weight: 400;
--ds-subtitle-size: 15px;
--ds-subtitle-leading: 1.5;
--ds-stat-font: var(--ds-font-display);
--ds-stat-size: 44px;
--ds-stat-leading: 1;
--ds-stat-tracking: -0.01em;
--ds-stat-weight: 400;
--ds-label-font: var(--ds-font-mono);
--ds-label-size: 10.5px;
--ds-label-leading: 1.35;
--ds-label-tracking: 0.1em;
--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: 11.5px;
--ds-meta-tracking: 0;
--ds-meta-color: var(--ds-text-muted);
}
/* The display face is set in italic wherever the contract uses it. */
.ds-folio :is(.ds-title, .ds-stat, .ds-mark) {
font-style: italic;
}
@media (prefers-reduced-motion: reduce) {
.ds-folio *,
.ds-folio *::before,
.ds-folio *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* <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> ------------------------------------------------------- */