Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions docs/STYLING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Styling System

Components in the Puck editor are styled through **design tokens** — named choices that map to arbitrary properties, but mostly Tailwind classes. Editors pick tokens from dropdowns; the system turns those choices into class names at render time.

Any token can be made **responsive**, letting editors pick different values per breakpoint (base / tablet / desktop). Unset breakpoints inherit from the nearest smaller one.

## Core concepts

### 1. Tokens

A token is a constrained set of options. `defineToken` creates one (`src/lib/puck/tokens.ts`). There are two forms:

**Class tokens** map each key to a Tailwind class string:

```ts
const padding = defineToken({
sm: { label: "Small", classes: "p-2" },
md: { label: "Medium", classes: "p-4" },
});

padding.classes // → { sm: "p-2", md: "p-4" }
```

You can add extra columns beyond `classes` — any property besides `label` becomes a `Record<K, V>` on the token:

```ts
const spacing = defineToken({
sm: { label: "Small", classes: "gap-2", px: 8 },
lg: { label: "Large", classes: "gap-6", px: 24 },
});

spacing.classes // → { sm: "gap-2", lg: "gap-6" }
spacing.px // → { sm: 8, lg: 24 }
```

**Plain tokens** use simple string values (key = value, string = editor label). Useful when there are no Tailwind classes to map to:

```ts
const variant = defineToken({ default: "Default", outline: "Outline" });

variant.options // → [{ value: "default", label: "Default" }, ...]
variant.defaultValue // → "default"
```

The first key is the default unless you pass an explicit second argument:

```ts
const size = defineToken({ sm: "Small", md: "Medium", lg: "Large" }, "md");
size.defaultValue // → "md"
```

Use `TokenValue<T>` to extract a token's value union for type annotations:

```ts
type Spacing = TokenValue<typeof padding>; // "sm" | "md"
```

### 2. Responsive values

A `ResponsiveValue<T>` has a required `base` and optional `md` / `lg` overrides (`src/lib/puck/responsive.ts`):

```ts
type ResponsiveValue<T> = { base: T } & Partial<Record<"md" | "lg", T>>;

{ base: "sm" } // same at all sizes
{ base: "sm", md: "md", lg: "lg" } // changes at each breakpoint
{ base: "sm", lg: "lg" } // skip md — it inherits from base
```

**Helper functions** for working with responsive values:

| Function | Purpose |
|---|---|
| `resolveAt(value, breakpoint)` | Returns the effective value at a breakpoint, falling back to the nearest smaller one. `resolveAt({ base: "sm", lg: "lg" }, "md")` → `"sm"` |
| `hasOverride(value)` | Returns `true` if any non-base overrides are set |
| `map(value, fn)` | Transforms each set breakpoint. `map(value, (v) => v.toUpperCase())` |
| `setAt(value, breakpoint, newVal)` | Returns a new `ResponsiveValue` with one breakpoint changed. Pass `undefined` to clear an override |

These helpers are for component logic — see [Rendering](#4-rendering) for how responsive values become Tailwind classes.

### 3. Props

`defineProps` wires tokens to Puck editor fields (`src/lib/puck/define-props.ts`). It returns `{ fields, defaultProps }` to spread onto a `ComponentConfig`.

There are four field builders:

```ts
const props = defineProps({
// Slot — a Puck drop zone for child components
content: field.slot(),

// Static select / radio — token-backed, single value
radius: field.select(radius, { label: "Corners" }),
layout: field.radio(layout, { label: "Layout" }),

// Responsive — per-breakpoint picker, also token-backed
padding: responsive.token(padding, { label: "Padding" }),

// Raw — any Puck field descriptor, for things tokens don't cover
url: field.raw({ type: "text", label: "URL" }, ""),
});
```

**Defaults.** Every builder falls back to the token's first key if no `default` is given. You can override with a scalar or a full responsive object:

```ts
responsive.token(columnCount, { label: "Columns", default: "3" })
responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "3" } })
```

**Slots** accept an optional allow/disallow list to restrict which child components can be dropped in:

```ts
field.slot({ allow: ["Card", "Button"] })
field.slot({ disallow: ["Section"] })
```

The same token definition works for both static and responsive use — only the field builder differs.

### 4. Rendering

Static tokens look up the class map directly. Responsive tokens go through `resolveResponsive` (`src/lib/puck/responsive-tailwind.ts`), which prefixes each class with its breakpoint:

```ts
// Static — direct lookup
radius.classes[r] // → "rounded-md"

// Responsive — produces prefixed classes for each set breakpoint
resolveResponsive(padding, paddingToken.classes)
// { base: "sm", md: "lg" } → "p-2 md:p-6"
```

Multi-class values get each utility prefixed individually:
`"grid-rows-2 auto-rows-[0] overflow-hidden"` → `"md:grid-rows-2 md:auto-rows-[0] md:overflow-hidden"`

Combine everything with `cn()` (re-exported from `src/lib/utils`):

```ts
const classes = cn(
"w-full",
bgColor.classes[bg], // static
resolveResponsive(py, paddingY.classes), // responsive
);
```

## Tailwind source hints

Because `resolveResponsive` builds prefixed class names like `md:p-4` at runtime, Tailwind's scanner never sees them in source. Every responsive token needs a corresponding `@source inline(...)` directive in `src/app/styles.css`:

```css
@source inline("{md:,lg:,}{p,px,py}-{0,1,2,4,6,8,12}");
@source inline("{md:,lg:,}columns-{1,2,3,4}");
```

Static tokens don't need this — their class strings appear literally in `tokens.ts` and are picked up by the scanner automatically.

## Creating a new component

This section walks through building a component from scratch.

### Step 1 — Define or reuse tokens

Check `src/lib/puck/tokens.ts` for existing tokens before creating new ones. The file already exports tokens for padding, gap, colors, radius, shadow, alignment, layout, width, columns, and grid rows.

If you need a new token, add it to `tokens.ts` and export a type alias:

```ts
export const fontSize = defineToken({
sm: { label: "Small", classes: "text-sm" },
base: { label: "Base", classes: "text-base" },
lg: { label: "Large", classes: "text-lg" },
});

export type FontSize = TokenValue<typeof fontSize>;
```

### Step 2 — Define the props type and field spec

Write a `Props` type that mirrors the field spec. Use `Slot` for drop zones, `ResponsiveValue<T>` for responsive fields, and the token's value type for static fields:

```ts
import type { ComponentConfig, Slot } from "@puckeditor/core";
import type { ResponsiveValue } from "@/lib/puck/responsive";
import { defineProps, responsive, field } from "@/lib/puck/define-props";
import { padding, bgColor, type Spacing, type Color } from "@/lib/puck/tokens";

type CardProps = {
content: Slot;
padding: ResponsiveValue<Spacing>;
bgColor: Color;
title: string;
};

const props = defineProps({
content: field.slot(),
padding: responsive.token(padding, { label: "Padding", default: "md" }),
bgColor: field.select(bgColor, { label: "Background" }),
title: field.raw({ type: "text", label: "Title" }, ""),
});
```

### Step 3 — Export the component config

Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive props and direct `token.classes[value]` lookups for static ones:

```ts
import { resolveResponsive } from "@/lib/puck/responsive-tailwind";
import { cn } from "@/lib/utils";

export const Card: ComponentConfig<CardProps> = {
label: "Card",
...props,
render: ({ content: Content, padding: pad, bgColor: bg, title }) => (
<div className={cn(bgColor.classes[bg], resolveResponsive(pad, padding.classes))}>
{title && <h3>{title}</h3>}
{Content && <Content />}
</div>
),
};
```

### Step 4 — Register the component

Add the export to your Puck config so the editor can use it.

### Step 5 — Add Tailwind source hints (responsive tokens only)

If your component introduces responsive tokens with class patterns not already covered in `src/app/styles.css`, add a `@source inline(...)` directive. Check what's already there first — common patterns like padding and gap are already covered.

### Checklist

- Token exists in `tokens.ts` (or you added a new one with a `type` alias)
- Props type matches the `defineProps` spec
- `...props` is spread on the config (this includes both `fields` and `defaultProps`)
- Static tokens resolved via `token.classes[value]`
- Responsive tokens resolved via `resolveResponsive(value, token.classes)`
- `@source inline(...)` added for any new responsive class patterns
3 changes: 2 additions & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import "dotenv/config";
import { Prisma, PrismaClient } from "../src/generated/prisma/client";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import fs from "fs";
import path from "path";
import type { Prisma } from "../src/generated/prisma/client";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
Expand Down
3 changes: 2 additions & 1 deletion src/app/editor/[id]/[slug]/EditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { Client } from "./client";
import { getDocumentById } from "../../../../lib/documents/queries";
import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url";
import { createEmptyPuckData } from "../../../../lib/puck/utils";

export default async function EditorPage({
documentId,
Expand Down Expand Up @@ -63,5 +64,5 @@ function resolveVersion(
return { data: versions[0].content as Data, versionId: versions[0].id };
}

return { data: { content: [], root: {} } as Data };
return { data: createEmptyPuckData() };
}
8 changes: 2 additions & 6 deletions src/app/editor/[id]/[slug]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,12 @@ export function Client({
}: {
documentId: number;
documentName: string;
data: Partial<Data>;
data: Data;
versionId?: number;
publishedVersionId?: number;
versions: Version[];
isArchived: boolean;
}) {
const [currentData, setCurrentData] = useState<Data>(data as Data);
const [versions, setVersions] = useState(initialVersions);
const [versionId, setVersionId] = useState(initialVersionId);
const [publishedVersionId, setPublishedVersionId] = useState(initialPublishedVersionId);
Expand All @@ -67,7 +66,7 @@ export function Client({
>
<Puck
config={config}
data={currentData}
data={data}
ui={{plugin: {current: "version-plugin"}}}
plugins={[VersionPlugin, blocksPlugin(), outlinePlugin()]}
permissions={isArchived
Expand All @@ -78,9 +77,6 @@ export function Client({
actionBar: ActionBarOverride,
headerActions: SaveButton
}}
onChange={(data) => {
setCurrentData(data);
}}
/>
</DocumentContext.Provider>
);
Expand Down
14 changes: 13 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import "./styles.css";
import { Toaster } from "@/components/ui/sonner";
import { DialogProvider } from "@/components/ui/dialog-provider";
import { Inter, Bebas_Neue } from "next/font/google";

const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});

const bebasNeue = Bebas_Neue({
weight: "400",
subsets: ["latin"],
variable: "--font-bebas-neue",
});

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<html lang="en" className={`${inter.variable} ${bebasNeue.variable}`}>
<body>
<DialogProvider>
{children}
Expand Down
41 changes: 40 additions & 1 deletion src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";

@source inline("{md:,lg:,}{p,px,py}-{0,1,2,4,6,8,12}");
@source inline("{md:,lg:,}gap-{0,1,2,4,6,8,12}");
@source inline("{md:,lg:,}grid-cols-{1,2,3,4,5,6}");
@source inline("{md:,lg:,}grid-rows-{none,1,2,3,4,5,6}");
@source inline("{md:,lg:,}auto-rows-{auto,[0]}");
@source inline("{md:,lg:,}overflow-{visible,hidden}");
@source inline("{md:,lg:,}columns-{1,2,3,4}");
@source inline("{md:,lg:,}gap-x-{0,1,2,4,6,8,12}");

@custom-variant dark (&:is(.dark *));

@theme {
Expand All @@ -10,10 +19,11 @@
--color-solid-black: #000000;
--color-semi-transparent-white: rgba(255, 255, 255, 0.9);
--color-sga-red-alt: #dc2626;
--font-display: var(--font-bebas-neue), sans-serif;
}

:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: var(--font-inter), system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
Expand Down Expand Up @@ -71,3 +81,32 @@ body {
@apply bg-background text-foreground;
}
}

.prose h1 {
@apply font-display text-5xl;
}

.prose h2 {
@apply font-display text-4xl text-sga-red;
}

.prose h3, .prose h4, .prose h5, .prose h6 {
@apply font-display;
}

.prose ul {
@apply list-disc pl-6;
}

.prose ol {
@apply list-decimal pl-6;
}

.prose p:has(br:only-child),
.prose p:empty {
@apply block min-h-lh;
}

.rich-text-line-spacing .rich-text * {
line-height: inherit;
}
Loading
Loading