# Mielui design

The `mielui` skill tells you which component to use and what its props are. This skill tells you where things go: the shell around the page, the page header, the grid, the spacing between sections, and what to leave out.

A page built from correct Mielui components can still look generic. That happens when every metric gets its own card, every section gets a border, and the spacing is one gap repeated everywhere. The rules below are how Mielui's own docs, Studio, and demo apps avoid that.

## How to work

1. Load the `mielui` skill first if Mielui is not installed or you have not read the component pages you need. Never guess a prop.
2. Write down the job in one sentence: who uses this page and what they do on it. Then name the dominant object, the one thing the page exists to show or collect. A dashboard's dominant object is usually one chart or one table, not six tiles.
3. Pick the archetype from the table below and read its reference file before writing markup.
4. Write the content before the layout. Use real product names, plausible numbers, real dates, and labels a person would write. Lorem ipsum and "Feature one" hide every hierarchy problem until it is too late.
5. Build in this order: base styles, shell, page header, the dominant object, supporting content, then the empty, loading, and error states.
6. Look at it. Open the page at 1440px and 390px, in light and dark. Run the checklist at the end of this file and fix the first thing that fails.

## Pick the archetype

| You are building | Read | The shape |
| --- | --- | --- |
| Any Mielui page | `references/foundations.md` | Base styles, the spacing and type scales, surfaces, color, icons, states |
| An app with several sections | `references/app-layouts.md` | A shell that stays fixed while one panel scrolls |
| An overview, analytics, or reporting page | `references/dashboards.md` | Header, one strip of numbers, one dominant chart, then the table |
| A list of records with a detail view | `references/dashboards.md` | Data table with a toolbar, a sheet or split pane for the record |
| Settings or a long form | `references/app-layouts.md` | A narrow column of labelled groups, with no card around each field |
| A chat or agent workspace | `references/app-layouts.md` | Thread list, transcript column, composer pinned at the bottom |
| A landing page or product site | `references/marketing-sites.md` | Left-aligned copy beside the working product, then a few plain sections |

Read `references/foundations.md` for every task. It is short, and the other files assume it.

## The look

White plates rest on a quiet grey stage, drawn in one ink. Everything pressable shares one radius, filled actions are lit, and nothing moves unless a state changed.

Seven rules produce that look. Break one and the page stops reading as Mielui.

1. **Stage and plates.** The window is the stage, `bg-background`. Content sits on plates, `bg-card` with a hairline. In dark mode the plate is the darkest surface and the stage sits one step lighter around it. A page has few plates: often one for the whole page, or one per real group. A recessed inset is the stage color again, so it only reads inside a plate.
2. **Space divides, rules do not.** Sections are separated by a larger gap, not a horizontal line. A hairline is for rows inside one group, or for the one edge between a sidebar and its page.
3. **Never a card in a card.** If a card needs an inner region, use `Card.Root variant="inset"`, which is built for it. Do not wrap text, a list, or navigation in a card to make it look designed.
4. **Weight stops at 500.** `font-semibold` and `font-bold` both render at 500. Hierarchy comes from size, from `text-foreground` against `text-foreground-muted`, and from spacing.
5. **One accent, spent once.** The primary color belongs to the primary action on the page and the first data series. Status colors are text tints and small dots. A status never fills a panel.
6. **One radius for controls.** Buttons, fields, tabs, and chips already use `--radius-control`. A custom pressable element uses `rounded-[var(--radius-control)]`. Never write `rounded-full` or `rounded-lg` on a control.
7. **Hover changes color only.** Nothing lifts, scales, or glows on hover. Nothing fades in on scroll. Use the motion that components already have.

## What a finished page has

Agents tend to stop at the happy path with placeholder content. A page is finished when it has all of these.

- A page title and one muted sentence under it, with the page's main action at the end of the same row. A chat thread is the exception: it has a one-row title bar.
- One primary button in view at most. Every other action is `outline`, `ghost`, or `quiet`. A page with no main action has no primary button, and that is fine.
- Numbers in `tabular-nums`, formatted with `Intl.NumberFormat`, with their unit.
- Real interactions. Tabs switch, filters filter, the dialog opens and its form submits, the row menu does something and confirms it with a toast.
- An empty state with one action, a loading state that keeps the layout's shape, and an error that names the cause and the way out.
- A layout that recomposes at narrow widths: the sidebar becomes a sheet, grids drop to one column, and a wide table scrolls inside its own frame.
- Both themes. Use tokens for every color, so dark mode needs no extra work.

## Do not ship

- A centered hero with a gradient, followed by three cards with an icon tile each.
- A row of stat cards, each with its own border, icon, and a green percentage pill.
- All-caps tracked eyebrows above headings, or section numbers like "01".
- Badges for plain metadata. A Badge is for a state that matters, such as Overdue or Draft.
- Decorative gradients, blobs, glows, or hand-written `backdrop-blur`. Glass is a theme setting.
- Emoji as icons, mixed icon sets, or an icon in front of every label.
- Placeholder avatars from a photo service, stock imagery, or fake customer logos.
- A footer strip showing version numbers or status that nobody asked for.
- Raw hex colors, `shadow-lg`, or a grey picked by eye. Use the tokens.
- A link to a page that does not exist. If the brief has no sign-in or docs route, open a Dialog or leave the link out.

Avoiding these is not permission to ship an empty template. Commit to a composition: one dominant object, a clear grid, dense where people compare and loose where they read.

## Checklist before you call it done

1. Does the first screen show the task and the dominant object without scrolling?
2. Can you remove a border, card, icon, badge, or label without losing meaning? Then remove it.
3. Do peers match? Same size, weight, alignment, and number format for things of the same kind.
4. Is the gap between sections clearly larger than the gap inside a section?
5. Is there at most one primary button in view?
6. Does it hold at 390px wide with no horizontal page scroll?
7. Does dark mode look deliberate, with no white box or invisible border?
8. Do keyboard focus, Escape, and outside click work on every overlay you added?
9. Is the browser console free of errors and warnings from your code?

Report what you built, which archetype you used, and what you did not verify. If a Mielui component itself logs a warning or misbehaves, say which one and how to reproduce it. Do not hide it with a workaround you do not mention.

The reference files this skill names follow below, in the order of the archetype table.

## Foundations

Read this before any layout work. It covers the base stylesheet, the scales, and the handful of classes that every Mielui page is built from.

### Base styles

`ui.css` brings Tailwind, the tokens, and the fonts. It does not style `body`, so an app that only imports it gets a white page in the browser's default font. Add the base once in `src/app.css`.

```css
@import '@mielui/svelte/ui.css';

@layer base {
    html {
        scrollbar-gutter: stable;
        -webkit-font-smoothing: antialiased;
    }

    body {
        margin: 0;
        background-color: var(--color-background);
        color: var(--color-foreground);
        font-family: var(--font-sans), sans-serif;
        font-size: var(--font-size-body);
        line-height: 1.5;
        font-synthesis-weight: none;
    }

    h1,
    h2,
    h3 {
        text-wrap: balance;
    }

    p {
        text-wrap: pretty;
    }
}
```

Dark mode is the `dark` class on `<html>`. Any toggler works. `mode-watcher` is the one the docs use: render `<ModeWatcher />` once in the root layout and call `toggleMode()` from a ghost icon Button. Render `<Toaster />` once in the root layout too.

The toggle shows the theme it switches to. Read `mode.current` from `mode-watcher` to choose between the sun and moon glyphs and to word the `aria-label`.

Use tokens for every color and both themes come for free. A `dark:` color class in your own markup is a sign that you picked a color by hand.

### The spacing unit is 3.6px

Mielui sets Tailwind's spacing unit to 3.6px, not 4px. `p-4` is 14.4px and `gap-6` is 21.6px. Everything is about ten percent tighter than stock Tailwind, which is part of why Mielui looks dense and calm. Do not compensate with arbitrary pixel values. Pick from this scale.

| Use | Class | Size |
| --- | --- | --- |
| Icon to its label, a dot to its text | `gap-2` | 7px |
| A title to its description | `gap-1` or `mt-1` | 4px |
| Controls in one row | `gap-2` | 7px |
| Fields in one form group | `gap-4` | 14px |
| Items in a card grid | `gap-4` to `gap-6` | 14 to 22px |
| Blocks inside one section | `gap-6` | 22px |
| Sections of an app page | `gap-10` to `gap-12` | 36 to 43px |
| Sections of a marketing page | `gap-24` to `gap-32` | 86 to 115px |
| Page gutter | `px-5 sm:px-8 lg:px-10` | 18, 29, 36px |
| Page top and bottom | `pt-8 pb-16` | 29, 58px |

Give each gap one owner. Put `flex flex-col gap-*` or `grid gap-*` on the parent and remove margins from the children. The ratio matters more than the numbers: the gap between sections should be at least twice the gap inside one.

Widths are in rem, so they are unaffected by the unit. Useful ones: `max-w-2xl` (42rem) for a form or settings column, `max-w-[60rem]` for an article, `max-w-[84rem]` for a marketing page, and no cap for a dashboard.

### The type scale

Body text is 14px. In this theme `text-sm` and `text-base` are both 14px, `text-xs` is 12px, `text-xl` is 20px, and `text-3xl` is 30px. Other sizes keep Tailwind's values.

| Role | Classes | Size |
| --- | --- | --- |
| Marketing headline | `text-5xl font-medium tracking-tight leading-[1.05]`, `text-4xl` on phones | 48px |
| Page title | `text-xl font-medium tracking-[var(--tracking-header)]` | 20px |
| Big number | `text-2xl font-medium tabular-nums` or `text-3xl` for the hero number | 24 or 30px |
| Section heading | `Typography.Title`, or `text-[length:var(--font-size-header)] font-medium` | 16px |
| Card title | `Card.Title` | 16px |
| Body, labels, table cells | `text-sm` | 14px |
| Sentence under a page title | `text-sm text-foreground-muted` in apps, `text-lg text-foreground-muted` under a marketing headline | 14 or 18px |
| Metadata, captions, axis labels | `text-xs text-foreground-muted` | 12px |
| Code, IDs, timestamps, key hints | `font-mono text-xs` | 12px |

Rules that keep type clean:

- Two text colors do almost all the work: `text-foreground` and `text-foreground-muted`. Do not invent a third grey with opacity.
- Sentence case everywhere, including buttons, tabs, and table headers.
- No uppercase labels and no wide tracking.
- Body copy runs 60 to 68 characters per line. Cap prose with `max-w-prose` or `max-w-xl`.
- Mono is for things a person might copy: IDs, commands, paths, hashes. It is not a style for labels.

The `Typography` components carry the right classes. `Typography.Title level={2}` is a section heading, `Typography.Description` is the muted sentence under it, and `Typography.Metadata` is 12px muted text. Passing `class` overrides their defaults, so `class="text-xl"` on a Title makes it a page title.

### Surfaces

There are five surfaces. Use the class or component, never rebuild one with borders and shadows.

| Surface | How | Use it for |
| --- | --- | --- |
| Stage | `bg-background` | The window behind everything |
| Plate | `mielui-plate`, or `Card.Root` | A resting group: a panel, a card, the page itself |
| Two-layer frame | `Card.Root variant="inset"`, or `mielui-inset-frame` holding `mielui-inset-surface` | A group with its own toolbar, tabs, or footer. Content sits in the recessed inset, chrome sits on the frame |
| Floating panel | `mielui-float-frame`, used by menus, popovers, selects | Anything anchored to a trigger. You rarely write this yourself |
| Modal | Dialog, Sheet, Drawer, Command | Tasks that take over |

Choosing between them:

- Start with no surface. A heading and its content on the plate or stage need nothing around them.
- Reach for a plain `Card.Root` when a group is a real object: something you could move, link to, or act on.
- Reach for `variant="inset"` when the group has a chart, a table, or a list plus actions. The footer strip takes the actions and the inset takes the content.
- Match the surface to what is behind it. An inset is the stage color. On a plate it reads as recessed. Directly on the stage it disappears, grey on grey. So when the page sits on the stage, as in the top bar shell or a marketing page, groups are plain `Card.Root` plates. When the page is itself a plate, groups inside it are inset cards or nothing at all.
- `DataTable` draws its rows on an inset. On the stage, put it in a plate: `<div class="mielui-plate p-4">`.
- On an inset Card, `Card.Header` sits inside the inset with the content and `Card.Footer` is the one strip on the frame. Do not add a second strip of your own above or below it.
- Fields inside an inset are white, so they read as raised. That is intended.

Borders use `border-border` with `border-[length:var(--border-size)]`. A hover or selected row uses `bg-[var(--color-wash)]`. A flat grey fill for a track or chip is `bg-secondary`.

### Color

| Token | Meaning |
| --- | --- |
| `bg-background` | The stage |
| `bg-card` | Plates and fields |
| `bg-secondary` | Tracks, chips, the selected row in a nav |
| `text-foreground`, `text-foreground-muted` | The two inks |
| `border-border`, `border-border-strong` | Hairlines |
| `bg-primary`, `text-[var(--color-on-primary)]` | The one accent and the text that sits on it |
| `text-[var(--mielui-success-text)]`, and the `warning`, `error`, `info` versions | Status as text, readable on any surface |
| `bg-success`, `bg-warning`, `bg-error`, `bg-info` | Status as a 6 to 8px dot |
| `var(--chart-1)` to `var(--chart-5)` | Data series, in order |

A delta such as "+12.4%" is `text-[var(--mielui-success-text)]` with `tabular-nums`. It is not a pill. A status in a table is a Badge with a status variant, or a dot and a word.

To rebrand, set `--color-primary` and `--color-on-primary` on `:root` and `.dark` after the import. Do not recolor individual components.

### Radius

You almost never write a radius. Components bring their own. When you build something custom:

- Pressable things and menu rows: `rounded-[var(--radius-control)]`. It is 12px by default, 4px in the sharp scale, and a pill only in the rounded scale.
- A row inside a plain list or nav: `rounded-[var(--radius-md)]`.
- A plate: use `mielui-plate`, which also gives the squircle corner.
- A thing nested inside a rounded thing: inner radius = outer radius minus the gap between them. If a button sits 8px inside a panel corner, the panel's radius is the button's radius plus 8px. Change the outer radius or the gap, never hand-pick the inner one.
- Round by nature: avatars, dots, switch thumbs. Those keep `rounded-full`.

### Links and focus

`ui.css` does not style bare links or add a global focus ring. Components bring their own. For anything you write by hand:

- A text link is `text-foreground-muted hover:text-foreground transition-colors`. A link inside a sentence is `text-foreground underline underline-offset-4`.
- Every custom focusable element gets `rounded-[var(--radius-sm)] focus-visible:outline-none focus-visible:shadow-[var(--focus-ring)]`. Use the control radius when it is a row or button.
- Prefer `Button` with `href` over a hand-styled link whenever the thing looks like a control.

### Fields

- `Input`, `Textarea`, `Switch`, `Checkbox`, `Slider`, and radio items take a `label` prop, and most take `description`. Use them instead of writing a label element.
- A control with no `label` prop, such as `Select`, goes in `Field.Root` with `Field.Label`, so the label is still wired to the control.
- Validation messages come from `Field.Root` with `issues` and `Field.Error`. Show them under the field when it is left or the form is submitted, not on every keystroke.
- A few tabs used as a switch with no panels, such as a range or a billing period, need a group name: wrap `Tabs.List` in `<div role="group" aria-label="Range">`.

### Brand mark

A product needs a mark in the header and a favicon. Make one small tile: `grid size-6 place-items-center rounded-[var(--radius-sm)] bg-primary text-[var(--color-on-primary)]` holding a simple inline SVG glyph or one letter. Reuse the same shape as `static/favicon.svg`. This is the only tinted tile on the page.

### Icons

Use Hugeicons through `HugeiconsIcon`, with glyphs from `@hugeicons/core-free-icons`.

```svelte
<script lang="ts">
    import { Search01Icon } from '@hugeicons/core-free-icons';
    import HugeiconsIcon from '@mielui/svelte/hugeicons-icon';
</script>

<HugeiconsIcon icon={Search01Icon} size={16} aria-hidden="true" />
```

- 16px in controls and nav rows, 14px in dense rows and menus, 20px at most for an empty state.
- Icons go on actions and navigation. They do not go in front of every heading, stat, or feature.
- An icon-only button needs `aria-label` and a Tooltip. Wrap menu and sheet triggers the same way: `Tooltip.Trigger` around the `DropdownMenu.Trigger`.
- Menu items are text. Add icons to a menu only when every item has one and they help people scan.
- Never put an icon on a tinted rounded tile to decorate a card. The only tile in Mielui is the brand mark.

### States

Every view that loads or lists data needs these four. Build them, do not describe them.

- **Empty.** `EmptyState` with a title, one sentence, and one action. Say what will appear here and how to make the first one.
- **Loading.** `Skeleton` blocks in the exact shape of the content that is coming, so nothing jumps. A button keeps its label and gains a spinner through `loading`.
- **Error.** `Alert variant="error"` next to the thing that failed, naming the cause and offering a retry. Not a toast.
- **Success.** A toast for a one-off outcome ("Invoice sent"). A changed label for a standing one ("Saved").

### Copy

- Buttons say what happens: "Create invoice", "Send reminder", "Export CSV". Not "Submit" or "OK".
- Headings describe the content: "Revenue by plan", not "Insights".
- One muted sentence explains a page or section. If it needs three, the layout is unclear.
- No exclamation marks, no "Welcome back!", no slogans as labels.

## App layouts

An app is a shell that stays put and one region that scrolls. Pick the shell first, then lay out the page inside it.

The skeletons here are layout only. Check each component's page through the `mielui` skill before relying on a prop.

### Three shells

| Shell | Use it when | Navigation |
| --- | --- | --- |
| Sidebar shell | The default. Anything people work in all day: admin, records, a chat workspace | A labelled sidebar on the stage, one plate for the page |
| Rail shell | Several top-level areas that each have their own sub-navigation | An icon rail on the stage, a plate that holds a secondary sidebar and the page |
| Top bar shell | A small tool or a read-mostly dashboard with two to four views | A single header row, the page in a centered column |

All three share the same rules.

- The shell is fixed to the viewport with `fixed inset-0`. The document never scrolls. Only the page region does, with `overflow-y-auto overscroll-contain`.
- Navigation sits directly on the stage. It has no card, no border of its own, and no background.
- The page is one plate, inset two spacing units from the window edge. Its hairline and corner are the only frame on screen.
- At most one hairline divides regions inside the plate, such as a secondary sidebar from the page.
- Below `lg` the sidebar or rail is replaced by a slim header with a menu button that opens the same navigation in a `Sheet` from the left. Rows in that sheet are taller for touch: `h-11` instead of `h-9`.

### Sidebar shell

```svelte
<script lang="ts">
    import { page } from '$app/state';
    import HugeiconsIcon from '@mielui/svelte/hugeicons-icon';

    let { children } = $props();

    const row =
        'flex h-9 items-center gap-2.5 rounded-[var(--radius-md)] px-2.5 text-sm text-foreground-muted transition-colors hover:bg-[var(--color-wash)] hover:text-foreground focus-visible:outline-none focus-visible:shadow-[var(--focus-ring)] aria-[current=page]:bg-secondary aria-[current=page]:text-foreground';
</script>

<div class="fixed inset-0 flex bg-background text-foreground">
    <aside class="hidden w-60 shrink-0 flex-col lg:flex">
        <div class="flex h-14 shrink-0 items-center px-3">
            <!-- Workspace switcher: DropdownMenu.Trigger variant="quiet", full width -->
        </div>
        <nav aria-label="Main" class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-3">
            {#each links as link (link.href)}
                <a
                    href={link.href}
                    aria-current={page.url.pathname === link.href ? 'page' : undefined}
                    class={row}
                >
                    <HugeiconsIcon icon={link.icon} size={16} aria-hidden="true" />
                    <span class="min-w-0 flex-1 truncate">{link.label}</span>
                </a>
            {/each}
        </nav>
        <div class="shrink-0 p-3">
            <!-- Account menu: DropdownMenu.Trigger variant="quiet" with an Avatar and a name -->
        </div>
    </aside>

    <div class="flex min-w-0 flex-1 flex-col p-2 lg:ps-0">
        <header class="flex h-12 shrink-0 items-center gap-2 px-2 lg:hidden">
            <!-- Sheet.Trigger (ghost, icon) with the same nav, then the brand, then search -->
        </header>
        <main class="mielui-plate @container flex min-h-0 flex-1 flex-col overflow-clip">
            <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
                {@render children()}
            </div>
        </main>
    </div>
</div>
```

Details that make it look right:

- The sidebar is 15rem (`w-60`). Rows are 9 units tall with a 16px icon and 14px muted text. The current row is `bg-secondary` with full-color text. Hover is the wash.
- Group long navigation under small labels: `px-2.5 pt-4 pb-1 text-xs font-medium text-foreground-muted`. No dividers between groups.
- A count beside a row is plain muted `font-mono text-xs tabular-nums`, not a badge.
- The workspace switcher and the account menu are `quiet` dropdown triggers that fill the sidebar's width. A square Avatar marks the workspace, a round one marks the person. With only one workspace, show the brand and name as plain text. Do not build a switcher that switches nothing.
- Put search in the sidebar's top or the page header as an outline `Command.Trigger` with a `Kbd` hint. Open it from a `keydown` listener on `<svelte:window>` that checks for K with `metaKey` or `ctrlKey`, so the shortcut works on every platform and while the trigger is hidden.
- Keep the theme toggle in the account menu or as one ghost icon button at the bottom of the sidebar.

### Rail shell

The rail is 17 units wide (`w-17`) and holds icons only, each with a Tooltip on its right. It has three groups: the brand and the sidebar toggle at the top, the primary areas in the middle, and search, theme, and account at the bottom. Use `justify-between` so the groups spread evenly.

```svelte
<div class="fixed inset-0 flex bg-background text-foreground">
    <nav aria-label="Primary" class="hidden w-17 shrink-0 flex-col items-center justify-between py-3 lg:flex">
        <div class="flex flex-col items-center gap-1"><!-- brand, sidebar toggle --></div>
        <div class="flex flex-col items-center gap-1"><!-- areas: ghost icon buttons --></div>
        <div class="flex flex-col items-center gap-1"><!-- search, theme, account --></div>
    </nav>
    <div class="flex min-w-0 flex-1 p-2 lg:ps-0">
        <div class="mielui-plate flex min-h-0 min-w-0 flex-1 overflow-clip">
            <aside class="hidden w-60 shrink-0 flex-col border-e-[length:var(--border-size)] border-border pt-3 lg:flex">
                <!-- The current area's own navigation: plain text rows, no icons -->
            </aside>
            <main class="@container min-w-0 flex-1 overflow-y-auto overscroll-contain">
                <!-- page -->
            </main>
        </div>
    </div>
</div>
```

- A rail item is a ghost icon Button. The current one is marked with `bg-secondary text-foreground`, the rest are `text-foreground-muted`.
- Controls keep their position on every page. If an area has no secondary sidebar, disable the sidebar toggle. Do not remove it.
- For a stronger frame, the docs paint the stage near-black in both themes: put the `dark` class on the rail so its tokens resolve dark, and set the outer background to `bg-[#060606]`. This is the one raw hex the system allows. Use it only when the product wants heavy chrome. The default is the grey stage.

### Top bar shell

```svelte
<div class="flex min-h-dvh flex-col bg-background text-foreground">
    <header class="sticky top-0 z-20 bg-background">
        <div class="mx-auto flex h-14 w-full max-w-[84rem] items-center gap-6 px-5 sm:px-8">
            <a href="/" class="text-sm font-medium">Product</a>
            <nav aria-label="Main" class="hidden items-center gap-1 md:flex"><!-- ghost Buttons with href --></nav>
            <div class="ms-auto flex items-center gap-1"><!-- search, theme, account --></div>
        </div>
    </header>
    <main class="@container mx-auto w-full max-w-[84rem] flex-1 px-5 pt-6 pb-16 sm:px-8">
        <!-- page -->
    </main>
</div>
```

Here the document scrolls and the page sits on the stage, with plates only around real groups.

- The header's content and the page share one max width, so the brand lines up with the page title. Use `max-w-[84rem]` for a dashboard and narrower for a tool with one form.
- Groups are plain `Card.Root` plates, not inset cards. An inset on the stage disappears.
- The current section's link is `bg-secondary text-foreground`. The others are ghost.
- Below `md` the links move into a second row under the header that scrolls sideways, or into a `Sheet` when there are more than four.

### Inside the plate

Every page opens the same way: a header row, then content.

```svelte
<div class="flex flex-col gap-10 px-5 pt-8 pb-16 sm:px-8 lg:px-10">
    <header class="flex flex-wrap items-end justify-between gap-4">
        <div class="flex min-w-0 flex-col gap-1">
            <h1 class="text-xl font-medium tracking-[var(--tracking-header)]">Invoices</h1>
            <p class="text-sm text-foreground-muted">Review, remind, and record payment.</p>
        </div>
        <div class="flex items-center gap-2">
            <Button variant="outline">Export CSV</Button>
            <Button>New invoice</Button>
        </div>
    </header>

    <section class="flex flex-col gap-4">
        <!-- the dominant object -->
    </section>
</div>
```

- The title is 20px and the sentence under it is muted 14px. Actions sit at the end of the same row. On a narrow screen they wrap under the title. With more than two, the main one stays and the rest move into a `DropdownMenu`.
- The page's main action, if it has one, is the only primary button. A page that only shows things has none.
- A page deeper than one level gets a `Breadcrumb` above the title. A record's title can carry one status Badge beside it.
- Views of the same page are `Tabs` with `variant="ghost"` under the header. Filters and ranges are ghost Tabs or a `Select` at the end of the header row.
- Size the content to its job: a dashboard fills the plate, a form is `max-w-2xl`, and an article is `max-w-[60rem]`. Do not center a dashboard in a narrow column.
- Use container variants (`@2xl:`, `@4xl:`) on content, not viewport ones. The plate changes width when the sidebar opens, and container queries follow it.

### Settings and long forms

A settings page is a column of groups. Each group has a title and a sentence at the start and its controls beside them.

```svelte
<div class="@container flex max-w-4xl flex-col gap-12">
    <section class="grid gap-x-12 gap-y-4 @3xl:grid-cols-[16rem_minmax(0,1fr)]">
        <div class="flex flex-col gap-1">
            <h2 class="text-[length:var(--font-size-header)] font-medium">Workspace</h2>
            <p class="text-sm text-foreground-muted">Shown on invoices and receipts.</p>
        </div>
        <div class="flex flex-col gap-4">
            <Input label="Workspace name" bind:value={name} />
            <Switch
                bind:checked={autoReconcile}
                label="Auto-reconcile"
                description="Match confirmed bank payments as they arrive."
            />
            <div><Button>Save changes</Button></div>
        </div>
    </section>
</div>
```

- The two-column form is at most `max-w-4xl`. A single column of fields, with each group's title above its controls, is `max-w-2xl`. Use the single column in a Dialog or Sheet, where there is no room for the title column.
- No card around a group and none around a field. The gap between groups is the divider.
- Use each field's own `label` and `description` props. A control without them goes in `Field.Root` with `Field.Label`. Validation shows through `Field.Error`.
- Buttons keep their natural width at the start of the group. Never stretch a button across the column.
- One Save for the page, at the end of the page header, disabled until something changed, with a ghost Discard beside it. Keep that header row in view with `sticky top-0 z-10` and the background of the surface it sits on. When groups save on their own, each gets an `outline` Save and the page has no primary button.
- A destructive group goes last, with a `destructive` button that opens an `AlertDialog`.
- With more than six groups, add a section list at the start: vertical `Tabs`, or a small nav using the sidebar row style.

### Record detail

```svelte
<div class="grid gap-10 @4xl:grid-cols-[minmax(0,1fr)_20rem]">
    <div class="flex min-w-0 flex-col gap-10"><!-- timeline, line items, notes --></div>
    <dl class="flex flex-col self-start">
        <div class="flex justify-between gap-4 border-b border-border py-3 text-sm">
            <dt class="text-foreground-muted">Due</dt>
            <dd class="tabular-nums">14 Nov 2026</dd>
        </div>
    </dl>
</div>
```

Facts are a `dl` of rows divided by hairlines, with the muted label first. Open a record from a table in a `Sheet` when people return to the list often, and on its own page when they stay and edit.

Drive the sheet from the URL, such as `?order=KLN-2318`, so a record can be linked and the back button closes it. Widen `Sheet.Content` with a width class when the record has line items. The default width suits a short list of facts.

### Chat and agent workspaces

Use the sidebar shell, with the thread list as its navigation. The threads sit on the stage like any other nav. The plate holds the transcript, the composer under it, and at most one side pane.

```svelte
<main class="mielui-plate flex min-h-0 flex-1 overflow-clip">
    <div class="flex min-w-0 flex-1 flex-col">
        <header class="flex h-14 shrink-0 items-center justify-between gap-3 px-5">
            <h1 class="min-w-0 truncate text-[length:var(--font-size-header)] font-medium">{thread.title}</h1>
            <div class="flex items-center gap-1"><!-- sources toggle, thread menu --></div>
        </header>
        <div class="min-h-0 flex-1"><!-- Conversation.Root fills this --></div>
        <div class="mx-auto w-full max-w-2xl shrink-0 px-4 pb-4 sm:px-6"><!-- Composer.Root --></div>
    </div>
    <aside class="hidden w-[22rem] shrink-0 flex-col border-s-[length:var(--border-size)] border-border xl:flex">
        <!-- sources or an artifact -->
    </aside>
</main>
```

- The sidebar holds a New thread row, a search row with a `Kbd` hint, then threads under small muted date labels. A thread row is one line of truncated text. The current thread uses `bg-secondary`. The account menu sits at the bottom.
- A thread has a one-row title bar, not a page header with a sentence. The title is 16px and truncates. Its actions are icon buttons at the end.
- The transcript and the composer share one centered column, at most 42rem (`max-w-2xl`), with the same side padding, so their edges line up. Check the `Conversation` page for how its content is already capped and padded before adding your own wrapper.
- Use `Conversation`, `Message`, `Reasoning`, `Tool`, `Markdown`, and `Composer`. The `mielui` skill's component selection guide shows how they nest. Do not draw chat bubbles by hand.
- User messages are short and sit at the end of the row. Assistant output is plain text on the plate with no bubble.
- Tools and reasoning are quieter than the answer. Give them labels that say what happened, such as "Searched 2 queries", and collapse them once the answer starts.
- One failure gets one error mark. Put an `Alert variant="error"` with a retry where the answer would be, and do not also mark the message and the tool row in red.
- Attachments are chips in a row directly above the composer.
- A new thread centers one group in the plate: a short heading, one sentence, three or four example prompts as outline Buttons, and the composer under them. Clicking a prompt fills the composer. After the first message the composer moves to the bottom.
- The side pane is the plate's one hairline. Below `xl` it opens as a `Sheet` from the end instead.

### Narrow screens

Decide what happens below `lg` and below `sm` for every region. The defaults:

- Sidebar or rail: hidden, replaced by a header with a menu button and a left `Sheet`.
- Multi-column grids: one column, in reading order. The dominant object comes first.
- Header actions: the primary button stays, the rest move into a `DropdownMenu` behind a ghost icon button.
- Tables: scroll inside their own frame, with the first column kept readable. Or switch to a list of rows showing the two fields that matter.
- Do not shrink controls to fit. Use the default control height or larger, and `h-11` rows in sheets.

## Dashboards and data pages

A dashboard answers one question first and a few more after. Lay it out in that order. Do not start from a grid of equal tiles and look for things to put in them.

### The order of a dashboard

1. Header: title, one muted sentence, the range control, and the page's action if it has one.
2. One strip of numbers: three to five figures that summarize the period.
3. The dominant object: the one chart or table the page exists for. It takes the most space.
4. Supporting views beside or under it: a breakdown, a ranked list, recent activity.
5. The records: a table people can search, sort, and act on.

```svelte
<div class="flex flex-col gap-10 px-5 pt-8 pb-16 sm:px-8 lg:px-10">
    <header class="flex flex-wrap items-end justify-between gap-4">
        <div class="flex flex-col gap-1">
            <h1 class="text-xl font-medium tracking-[var(--tracking-header)]">Overview</h1>
            <p class="text-sm text-foreground-muted">Traffic and conversions for acme.dev.</p>
        </div>
        <Tabs.Root bind:value={range} variant="ghost">
            <div role="group" aria-label="Range">
                <Tabs.List>
                    <Tabs.Trigger value="7d">7 days</Tabs.Trigger>
                    <Tabs.Trigger value="30d">30 days</Tabs.Trigger>
                    <Tabs.Trigger value="90d">90 days</Tabs.Trigger>
                </Tabs.List>
            </div>
        </Tabs.Root>
    </header>

    <!-- number strip -->
    <!-- chart grid -->
    <!-- table -->
</div>
```

The range control changes every number, chart, and table on the page. Wire it.

### The number strip

Numbers are text in a row, not a card each. Bound the strip with one hairline above and below, or put all of it in a single plate.

```svelte
<dl class="grid grid-cols-2 gap-x-8 gap-y-6 border-y border-border py-6 @3xl:grid-cols-4">
    {#each stats as stat (stat.label)}
        <div class="flex flex-col gap-2">
            <dt class="text-sm text-foreground-muted">{stat.label}</dt>
            <dd class="flex items-baseline gap-2">
                <span class="text-2xl font-medium tabular-nums">{stat.value}</span>
                <span class="text-xs tabular-nums {stat.up ? 'text-[var(--mielui-success-text)]' : 'text-[var(--mielui-error-text)]'}">
                    {stat.delta}
                </span>
            </dd>
        </div>
    {/each}
</dl>
```

- The label is muted 14px above the value. The value is 24px medium with `tabular-nums`.
- A delta is a small text tint beside the value with its sign: "+12.4%". It is not a pill, and it needs no arrow icon.
- Where a comparison means little, put one line of muted 12px context under the value: "$6,602 in progress", "across 8 orders".
- Tint the value itself only when it needs attention, such as a count of late orders in `text-[var(--mielui-error-text)]`.
- Say what the delta compares against once, in the page's sentence or a caption: "compared with the previous 30 days".
- Good is not always up. Color a falling churn rate as success.
- No icon per stat. Four icons in a row carry no information.
- Use `use:numberShuffle` from `@mielui/svelte/actions/number-shuffle` on a value that changes in place, such as when the range switches.

### Charts

Put a chart in a Card. The title and description go in the header, the headline figure sits above the plot, and the plot fills the width.

Which Card depends on what is behind it. When the page is a plate, as in the sidebar and rail shells, use `variant="inset"` as below. When the page sits on the stage, as in the top bar shell, drop the variant and use a plain `Card.Root`, because an inset on the stage is grey on grey.

```svelte
<div class="grid gap-4 @4xl:grid-cols-3">
    <Card.Root variant="inset" class="min-w-0 @4xl:col-span-2">
        <Card.Header>
            <Card.Title>Revenue</Card.Title>
            <Card.Description>January to June 2026</Card.Description>
        </Card.Header>
        <Card.Content class="flex min-w-0 flex-col">
            <p class="mb-5 text-3xl font-medium tabular-nums">{money.format(total)}</p>
            <Chart.Root data={revenue} config={revenueConfig} x="month" aria-label="Monthly revenue">
                <Chart.Plot class="h-64">
                    <Chart.Grid />
                    <Chart.XAxis />
                    <Chart.YAxis />
                    <Chart.Area key="revenue" />
                </Chart.Plot>
                <Chart.Tooltip />
            </Chart.Root>
        </Card.Content>
    </Card.Root>

    <Card.Root variant="inset" class="min-w-0">
        <!-- the supporting view -->
    </Card.Root>
</div>
```

`config` maps each series key to a label, a color, and a formatter:

```ts
const revenueConfig = {
    revenue: {
        label: 'Revenue',
        color: 'var(--chart-1)',
        format: (value: number) => money.format(value)
    }
};
```

Choosing the chart:

| The data is | Use |
| --- | --- |
| A value over time | `Chart.Area` for one series, `Chart.Line` for two to four |
| A few categories compared | `Chart.Bar` |
| Many categories ranked | A ranked list, shown below. It beats a bar chart with twelve tilted labels |
| Parts of a whole, five or fewer | `PieChart`, or a ranked list with percentages |
| One measured value against a limit | `Gauge` |
| Activity by day | `Heatmap` |
| One exact number | Text. Not every number needs a chart |

Rules:

- One dominant chart per page, spanning two of three columns. Supporting charts are smaller and fewer.
- Series colors are `var(--chart-1)` to `var(--chart-5)` in order. The first series is the one that matters most.
- Give every plot a fixed height (`h-56` to `h-72`) and every chart an `aria-label`.
- Every card in a grid row gets `min-w-0`, or a chart will push the grid wider than the page.
- Use `@container` variants for the grid, so it follows the panel and not the window.
- Do not add a chart because a space is empty. Reflow the grid.
- The tooltip lists every key in `config`. When a toggle switches the plotted series, pass a config with only the series on screen.
- A sentence beside the headline figure does more than a second chart: "a day on average, peaking at 6,928 on Sep 22".

### Ranked lists

A ranked list shows a name, a value, and a bar for the proportion. Use it for top pages, sources, countries, plans, and anything with long labels.

```svelte
<ul class="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 gap-y-1 text-sm">
    {#each pages as row (row.path)}
        <li class="col-span-3 grid h-8 grid-cols-subgrid items-center">
            <span class="relative flex h-full min-w-0 items-center">
                <span
                    aria-hidden="true"
                    class="absolute inset-y-0 start-0 rounded-[var(--radius-md)] bg-[var(--color-wash)]"
                    style:width={`${(row.views / max) * 100}%`}
                ></span>
                <span class="relative truncate px-2.5 font-mono text-xs">{row.path}</span>
            </span>
            <span class="text-end tabular-nums">{row.views.toLocaleString('en-US')}</span>
            <span class="text-end text-xs tabular-nums text-foreground-muted">{row.share}</span>
        </li>
    {/each}
</ul>
```

- The bar lives in the label's column only, so its rounded end never runs under the numbers.
- The bar is the wash, not a chart color, so the text stays readable over it.
- Sort by value and show eight rows at most. A ghost "View all 15" button under them opens a `Dialog` with the full list.
- Variants of one list, such as top, entry, and exit pages, are ghost `Tabs` at the end of the card's title row.

### Activity

Recent events are rows: a small status dot, one line of text, and a muted timestamp at the end.

```svelte
<ul class="flex flex-col">
    {#each events as event (event.id)}
        <li class="flex items-center gap-3 border-b border-border py-3 text-sm last:border-b-0">
            <span aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-success"></span>
            <span class="min-w-0 flex-1 truncate">{event.text}</span>
            <time class="shrink-0 font-mono text-xs text-foreground-muted">{event.at}</time>
        </li>
    {/each}
</ul>
```

Pair a status dot with words that say the status. Color alone is not enough.

### Tables

Use `Table` for rows you already have in order. Use `DataTable` when people sort, filter, select, or page. Read the Data Table page before writing one. It presents a TanStack Table instance from `@tanstack/svelte-table` version 9, where features and row models are registered once and passed to `createTable`:

```ts
import {
    type ColumnDef,
    createPaginatedRowModel,
    createSortedRowModel,
    createTable,
    rowPaginationFeature,
    rowSortingFeature,
    sortFns,
    tableFeatures
} from '@tanstack/svelte-table';

const features = tableFeatures({
    rowPaginationFeature,
    rowSortingFeature,
    sortedRowModel: createSortedRowModel(),
    paginatedRowModel: createPaginatedRowModel(),
    sortFns
});

const columns: ColumnDef<typeof features, Invoice>[] = [
    { accessorKey: 'client', header: 'Customer' },
    { accessorKey: 'status', header: 'Status' },
    { id: 'amount', header: 'Amount', accessorFn: (invoice) => invoice.cents },
    { id: 'actions', header: 'Actions', enableSorting: false }
];

const table = createTable({
    features,
    columns,
    get data() {
        return visibleInvoices;
    },
    getRowId: (invoice) => invoice.reference,
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } }
});
```

Filter the rows yourself in a `$derived` and hand the result to `data`. That keeps search and the status filter as plain state.

The shape of a data table view:

```svelte
<DataTable.Root {table}>
    {#snippet children()}
        <DataTable.Toolbar>
            <Group.Root aria-label="Invoice filters" class="w-full max-w-lg">
                <Input bind:value={query} aria-label="Search invoices" placeholder="Search customer or invoice…" class="min-w-0 flex-1" />
                <Group.Separator />
                <Select.Root bind:value={status}>
                    <Select.Trigger variant="outline" aria-label="Status" class="w-auto shrink-0">{statusLabel}</Select.Trigger>
                    <Select.Content>
                        <Select.Item value="all">All statuses</Select.Item>
                        <Select.Item value="open">Open</Select.Item>
                    </Select.Content>
                </Select.Root>
            </Group.Root>
            <DataTable.Sort {table} class="ml-auto" />
        </DataTable.Toolbar>
        <DataTable.View {table} caption="Invoices">
            {#snippet cell(cell)}
                <!-- branch on cell.column.id; fall back to <FlexRender {cell} /> -->
            {/snippet}
            {#snippet empty()}
                No invoices match your filters.
            {/snippet}
        </DataTable.View>
        <div class="flex flex-wrap items-center justify-between gap-3">
            <DataTable.Summary {table} />
            <DataTable.Pagination {table} />
        </div>
    {/snippet}
</DataTable.Root>
```

What makes a table read well:

- The first column identifies the row. Make it medium weight in the sans face, even when it is an ID, and put one line of muted 12px detail under it when that saves a column. Mono is for IDs shown as secondary detail.
- The identifier is a link that opens the record. Do not make the whole row a click target.
- Numbers align to the end with `tabular-nums`. Align the column's header the same way, in the `header` snippet, or the label and its values will not line up. Dates use one format throughout.
- Status is a `Badge` with a status variant, and only where status matters. Other columns stay plain text.
- In a pipeline of many steps, color the states that need attention and the finished one. The steps in between are `secondary` badges. Six colors in one column say nothing.
- Row actions sit behind one ghost icon button at the end that opens a `DropdownMenu`. Menu items take `callback`, not `href`.
- Search and filters join in one `Group` at the start of the toolbar. Sort and view options go at the end.
- Five to seven columns. Move the rest into the record's detail view.
- The empty snippet says why it is empty and what to do: "No invoices match your filters."
- Search, the status filter, sorting, and pagination all work. A toolbar that does nothing is worse than no toolbar.

Open a row in a `Sheet` from the end side for a quick look, with the facts as a `dl` and the actions in `Sheet.Footer`.

### Loading and empty dashboards

- While data loads, render the same grid with `Skeleton` in place of each value, chart, and row. Keep the heights identical. For a table, render skeleton cells in the real rows' shape, not one "Loading" row.
- A new workspace with no data shows one `EmptyState` in place of the charts, with the action that produces the first data. Do not render six empty charts.
- A chart with no data for the range keeps its card and says so in one muted line.
- A failed section shows an `Alert variant="error"` in its own card with a retry. The rest of the page still works.

### Layout traps

- A grid child without `min-w-0` lets a chart or table widen the page.
- A visually hidden label (`sr-only`) inside a sideways-scrolling table can stretch the page at phone width. Give its cell `relative`.
- On a phone, drop table columns until the row identifies itself and shows one fact, or render the same rows as a `ul` under a container query: identifier and detail at the start, amount and status at the end. The rest is in the detail sheet.
- Check the last row of a table and the end of the header on a phone: a row menu must still open inside the window.

## Marketing and product sites

A Mielui marketing page shows the product working and says what it does in plain words. It has no gradient hero, no icon grid, and no invented testimonials. What makes it look good is a large calm headline, a real interactive demo beside it, and a few sections that share one set of edges.

Here the document scrolls normally. Do not use the fixed app shell.

### The page

```svelte
<div class="flex min-h-dvh flex-col bg-background text-foreground">
    <header class="mx-auto flex h-16 w-full max-w-[84rem] items-center gap-8 px-5 sm:px-8">
        <a href="/" class="flex items-center gap-2 text-sm font-medium">Relay</a>
        <nav aria-label="Main" class="hidden items-center gap-1 md:flex"><!-- three to five ghost Buttons with href --></nav>
        <div class="ms-auto flex items-center gap-2"><!-- theme toggle, then the call to action as an outline Button --></div>
    </header>

    <main class="mx-auto flex w-full max-w-[84rem] flex-col gap-24 px-5 pb-24 sm:px-8 lg:gap-32">
        <!-- hero, then sections -->
    </main>

    <footer class="mx-auto w-full max-w-[84rem] px-5 py-10 sm:px-8"><!-- link columns --></footer>
</div>
```

One column, at most 84rem wide, holds the header, every section, and the footer. Every section starts on the same left edge. That shared edge does more for the page than any decoration.

### Hero

Copy at the start, the product at the end.

```svelte
<section class="grid items-center gap-12 pt-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:pt-20">
    <div class="flex flex-col items-start gap-6">
        <h1 class="max-w-[14ch] text-4xl leading-[1.05] font-medium tracking-tight sm:text-5xl">
            Invoices that chase themselves
        </h1>
        <p class="max-w-xl text-lg text-foreground-muted">
            Relay sends reminders, matches bank payments, and tells you who is late.
        </p>
        <div class="flex flex-wrap items-center gap-2">
            <Button size="lg" onclick={openSignup}>Start free</Button>
            <Button size="lg" variant="outline" href="/pricing">See pricing</Button>
        </div>
    </div>
    <div class="min-w-0"><!-- the live demo --></div>
</section>
```

- The headline is left-aligned, 48px, medium weight, tight tracking, and short. Limit its width in characters (`max-w-[14ch]` to `max-w-[18ch]`) so it breaks into two or three even lines.
- The headline says what the product does. "Invoices that chase themselves" works. "The future of finance" does not.
- The lead is one or two sentences at 18px in the muted color.
- Two buttons at most: one primary, one outline. Both `size="lg"`. The hero's button is the page's primary action, so the copy of it in the header is `outline`. The closing section repeats the primary once the hero is out of view.
- The hero's main button may use `variant="glow"`, the lit version of primary made for one headline call to action. Use it once per page or not at all.
- Buttons go to routes that exist. With no sign-up page in the brief, "Start free" opens a `Dialog` with a short form that validates and confirms.
- One line of muted 14px text under the buttons can answer the obvious objection: "Free for one project. No card required.".
- No eyebrow above the headline, no badge announcing a release, no row of customer logos unless they are real.
- The hero may carry the one flat brand tint on the page: `bg-[color-mix(in_oklab,var(--color-primary)_7%,var(--color-background))]` on a full-width band or a rounded plate. A flat tint, not a gradient, and nowhere else on the page.

### The demo is the product

The hero's second column holds a small working piece of the product, built from real Mielui components, on a plate. It is not a screenshot, an illustration, or a browser mockup. The page sits on the stage, so use a plain `Card.Root`: an inset card here would be grey on grey.

```svelte
<Card.Root class="min-w-0">
    <Card.Header>
        <Card.Title>Overdue</Card.Title>
        <Card.Description>Three invoices need a reminder</Card.Description>
    </Card.Header>
    <Card.Content><!-- real rows, a real chart, a real composer --></Card.Content>
    <Card.Footer>
        <Button variant="outline">Send reminders</Button>
    </Card.Footer>
</Card.Root>
```

- Pick one small task the product does and make it work: toggling a setting, sending a reminder, filtering a table, asking a question.
- Clicking things in it changes state. A demo people can use sells the product better than a paragraph.
- Keep it one height. If it has tabs, each tab is the same size.
- Fill it with the same believable content as the rest of the site.

### Sections

Each section answers one question a visitor has: what it does, how it works, what it costs, how to start. Three to five sections is enough.

A section puts a heading and one sentence at the start and the evidence beside it.

```svelte
<section class="grid gap-x-16 gap-y-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
    <div class="flex flex-col gap-3">
        <h2 class="text-2xl font-medium tracking-tight">Reminders on your schedule</h2>
        <p class="max-w-md text-foreground-muted">
            Choose when Relay follows up. It stops the moment a payment lands.
        </p>
    </div>
    <div class="min-w-0"><!-- the evidence --></div>
</section>
```

The sentence under a section heading is 16px, `text-[length:var(--font-size-header)]`. Body text at 14px looks small beside a 24px heading on a marketing page.

When the evidence is much taller than the heading, keep the heading in view with `lg:sticky lg:top-8 lg:self-start`, or put the heading above the evidence at full width. Do not leave a tall empty column under a two-line heading.

The evidence is something concrete, and it changes from section to section:

- A working component, such as a settings group, a table, or a chart.
- A `CodeBlock` with the real install command or a real API call. Use `lang="console"` for terminal output, so words in the output are not colored as shell keywords.
- A row group of facts, shown below.
- A short numbered sequence written as an `ol`, for how it works.

Do not give every section the same silhouette. If three sections in a row are a heading over three boxes, merge them or change their evidence.

### Feature lists

A list of features is rows, not a grid of cards with icons.

```svelte
<dl class="mielui-plate divide-y divide-border overflow-hidden">
    {#each features as feature (feature.name)}
        <div class="grid gap-x-8 gap-y-1 px-5 py-4 text-sm sm:grid-cols-[12rem_minmax(0,1fr)]">
            <dt class="font-medium">{feature.name}</dt>
            <dd class="text-foreground-muted">{feature.summary}</dd>
        </div>
    {/each}
</dl>
```

The name sits in a fixed column and the sentence beside it, so every row aligns. Six to eight rows read faster than six cards, and nobody misses the icons.

### Pricing

Plans are aligned columns, so people can compare across them.

- Two or three plans in one grid with equal columns: `grid gap-4 lg:grid-cols-3`. Three columns need about 18rem each, so they stack below `lg`. Each plan is a plain `Card.Root`.
- Inside each: the plan name, the price as `text-3xl font-medium tabular-nums` with a muted "per month" beside it, one sentence on who it is for, the button, then the feature list.
- Feature lines sit at the same height in every column. Write the same number of lines per plan, or use a comparison `Table` under the plans.
- The recommended plan has the only primary button. The others use `outline`. No ribbon, no scaled-up middle card, no "Most popular" badge.
- A monthly and yearly switch is `Tabs variant="segmented"` at the end of the title row, inside a `role="group"` wrapper with a label. It changes every price and the billing note under it.
- State what happens after the trial and whether a card is required, in muted text under the grid.

### Questions, closing, footer

- Questions are an `Accordion` in a `max-w-2xl` column under a plain heading. Five to seven real questions, with answers that include numbers.
- The closing section is a heading, one sentence, and the primary button, left-aligned like everything else. It is not a tinted band with centered text.
- The footer is muted 14px links in three or four columns under small medium labels, with one line for the company name and year. No oversized wordmark and no newsletter form unless there is a newsletter.

### Inner pages

Pricing, changelog, about, and docs pages share the header and footer. Each starts with a `text-4xl font-medium tracking-tight` title and a muted `text-lg` lead, then content in the same column. A text-heavy page caps its prose at `max-w-[60rem]` or narrower.

A changelog is a list of dated entries, not cards. Each entry is a two-column row: the date in a fixed column at the start, in muted mono, and the title, a type word, and the body beside it. Entries are separated by space. A filter by type is ghost `Tabs` at the end of the title row, and older entries sit behind a ghost "Show older updates" button.

### Proof without invention

Never invent customers, quotes, star counts, or usage numbers. If the brief gives real ones, show them as plain text or a `dl` of figures. If it gives none, the working demo is the proof.

### Narrow screens

- The hero stacks, copy first, demo second. The headline drops to `text-4xl`.
- Header links collapse into a `Sheet` or `DropdownMenu` behind a ghost icon button. The primary button stays visible.
- Two-column sections stack with the heading above its evidence.
- Pricing columns stack in order, recommended plan first if it is not already.
- The page scrolls normally. Never clip the hero to the viewport height.
