Skip to content
Open
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
21 changes: 21 additions & 0 deletions .agents/references/component-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,27 @@ Review whether component styling remains self-contained and consistent:
<div className="component" />
```

### Typography roles

Text that a component renders takes its size, line height, weight, and letter spacing from a typography role, not from a raw size + weight combination. The roles are listed in the Typography section of `docs/concepts/styling-theming.md`.

- Use the role utility (`astw:text-heading-md`, `astw:text-label-md`, ...) on the component's own element, or render `Text` when the element itself is the text.
- Do not write new raw combinations such as `astw:text-lg astw:font-semibold` or `astw:text-sm astw:font-medium`. Existing ones are being moved to roles.
- Combine a role only with the four modifiers: `astw:leading-none` (single-line text), `astw:tabular-nums`, `astw:font-medium` or `astw:font-semibold`, and `astw:uppercase`. Do not add other `leading-*`, `tracking-*`, or `text-[Npx]` values. 12px is the minimum size.
- A role carries no color. Set the color with a token such as `astw:text-muted-foreground`.
- A heading's level comes from the document structure, not from its size. Keep a title that is a heading in a heading element, and pick the role for its look. Do not change the element to get a different size, and do not turn a heading into a `div` to get a different line height. Under the `cream` and `bloom` palettes, `h1` to `h6` ignore `leading-none`; see the Typography section.

```tsx
// Good
<h3 data-slot="card-title" className="astw:text-heading-md astw:leading-none">{title}</h3>
<Text variant="body-sm" className="astw:text-muted-foreground">{updatedAt}</Text>
```

```tsx
// Bad: a raw size + weight combination and a line height outside the roles
<h3 className="astw:text-lg astw:font-semibold astw:leading-7">{title}</h3>
```

### Container queries

Prefer Tailwind container-query utilities with arbitrary values.
Expand Down
12 changes: 12 additions & 0 deletions .changeset/text-role-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@tailor-platform/app-shell": minor
---

Add the `Text` component. It applies a typography role with the `variant` prop and does not set a color. Heading roles render `h2`, body roles render `p`, and label and code roles render `span`. Use the `render` prop to choose a different element, such as the heading level.

```tsx
import { Text } from "@tailor-platform/app-shell";

<Text variant="heading-md" render={<h3 />}>Line items</Text>
<Text variant="body-md" className="text-muted-foreground">Orders sent this month.</Text>
```
18 changes: 15 additions & 3 deletions decisions/typography-roles.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Decision: typography role tokens

> Status: **Decided for the first change — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities. No component uses them yet.**
> Scope: role names and values, how they reach Tailwind, and the rules for combining them. This does not cover applying roles to AppShell components, a `Text` component, or sizes below 12px.
> Status: **Decided for the first two changes — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities, then a `Text` component on top of them. AppShell's own components do not use the roles yet.**
> Scope: role names and values, how they reach Tailwind, the rules for combining them, and the `Text` component (see "Second change" at the end). This does not cover applying roles to AppShell components or sizes below 12px.

## Context

Expand Down Expand Up @@ -84,5 +84,17 @@ Limit: this does not let a role replace a size that core sets on its own element
- **No visual change.** No component uses the roles yet. The default, cream, and bloom palettes render as before.
- **cream and bloom `h1` to `h6` rule.** Both palettes set `line-height: 1.2` and `letter-spacing: -0.03em` on `html :where(h1, h2, h3, h4, h5, h6)` outside any `@layer`. That rule wins over a role utility on those elements, so a heading role on an `h1` to `h6` gets the palette values for line height and letter spacing. It also wins over any `leading-*` or `tracking-*` class on those elements, including the `leading-none` in the card title pairing: an `h3` with `text-heading-md leading-none` has a line height of 1.2 under cream and bloom, and 1.0 under the default palette. The same classes on a `div` or `span` render as documented. This PR keeps the rule and documents the effect. Open question for the maintainers: what does this rule protect, and should it stay once heading roles exist?
- **Keyboard hint.** The earlier pairing table listed "keyboard hint" with `font-mono text-xs`. The only keyboard hint in the code (the sidebar ⌘K hint) uses `text-xs font-normal`. The new table has no keyboard hint row.
- **Follow-up changes** (not in this PR): apply the roles to core components; `overline`; migrate 10px and 11px text to 12px; a `Text` component; numeric DataTable columns with `tabular-nums` and right alignment as the default; a MetricCard value role and a display role above 24px.
- **Follow-up changes** (not in this PR): apply the roles to core components; `overline`; migrate 10px and 11px text to 12px; a `Text` component (added in a second change, see below); numeric DataTable columns with `tabular-nums` and right alignment as the default; a MetricCard value role and a display role above 24px.
- **Docs example.** The live example in the docs uses `div` and `p` elements. The docs app runs the bloom palette, and its `h1` to `h6` rule would otherwise change the heading rows.

## Second change: the `Text` component

> Status: **Decided.** Applying the roles to core components is a third change.

- **API.** `Text` takes the role as `variant`. The prop is not called `role`, because `role` is an ARIA attribute. The element is set with Base UI's `render` prop, as in `Button`, the sidebar primitives, and the breadcrumb. AppShell has no `as` or `asChild` prop.
- **Default elements.** The three heading roles render `h2`. The body roles, including the relaxed variants, render `p`. `label-md`, `label-sm`, and `code-sm` render `span`. All headings share one default so that no heading level is implied by size. `h2` is the default because a page has one `h1` (`Layout.Header`), and `Dialog.Title` from Base UI also renders `h2`. A default is hard to change later, because every call site without `render` would get a different element.
- **Lint.** The `jsx-a11y/heading-has-content` rule reports `render={<h3 />}` as an empty heading, and the function form `render={(props) => <h3 {...props} />}` too. Apps that enable the rule disable it on that line. A `level` prop would avoid this, but it would add a second way to choose the element next to `render`. The documentation shows the disable comment instead.
- **No color or modifier props.** Color and the four modifiers stay in `className`, as for the utilities. A consumer `leading-none`, `font-medium`, or `tracking-*` overrides the role even though AppShell's prefixed sheet loads later, because the role reads the shared `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` variables first. `tests/typography-compile.test.ts` checks this.
- **No `textVariants` export.** A role can already be applied with the `text-<role>` utility or with `Text`. A third way would add public surface with no use case that these two do not cover. Unlike `Button` and `Badge`, `Text` does not export its cva function. It can be exported later without a breaking change.
- **`code-sm` includes `font-mono`.** The utility cannot set the font family, but the component can.
- **cream and bloom.** The `h1` to `h6` rule is unchanged. The default `h2` takes the palette's line height and letter spacing under these palettes, the same as existing headings in AppShell components today.
26 changes: 22 additions & 4 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1304,9 +1304,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "e63d837190e71ff1",
"outline": "8ad61f58de459be2",
"snapshot": null,
"outputMd": "112b43c697d30369",
"outputMd": "c40075c1f34fb315",
"examples": "4d19f4f688e0f0ac"
}
},
Expand Down Expand Up @@ -1344,6 +1344,23 @@
"examples": "b07f258088e81cac"
}
},
"text": {
"slug": "text",
"kind": "code-backed",
"outline": "docs-src/components/text.docs.outline.md",
"output": "docs/components/text.md",
"examples": "docs-src/components/text.docs.examples.tsx",
"sources": ["packages/core/src/components/text/**"],
"claims": [],
"symbols": ["Text", "TextProps"],
"hashes": {
"typeSurface": "ed54a16ca677dbe3",
"outline": "b354c6be5f81ce2a",
"snapshot": "f5d9429ab497863a",
"outputMd": "6436f68b6b404f0b",
"examples": "4a832708dc79474b"
}
},
"textarea": {
"slug": "textarea",
"kind": "code-backed",
Expand Down Expand Up @@ -1676,7 +1693,7 @@
"packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165",
"packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "8034c7f885994b29",
"packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "e342ef02dc2b9b86",
"packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "112b43c697d30369",
"packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "c40075c1f34fb315",
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b",
"packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0",
"packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194",
Expand Down Expand Up @@ -1716,6 +1733,7 @@
"packages/core/skills/app-shell-patterns/references/components/spinner.md": "e05d94ba9c3d17da",
"packages/core/skills/app-shell-patterns/references/components/table.md": "f85ec64ceb7f4acf",
"packages/core/skills/app-shell-patterns/references/components/tabs.md": "30bb32e57fc78bd9",
"packages/core/skills/app-shell-patterns/references/components/text.md": "6436f68b6b404f0b",
"packages/core/skills/app-shell-patterns/references/components/textarea.md": "c940bc01cd573334",
"packages/core/skills/app-shell-patterns/references/components/timeline.md": "699249c9452485ab",
"packages/core/skills/app-shell-patterns/references/components/toolbar.md": "91d70b140c7bb747",
Expand Down Expand Up @@ -1757,6 +1775,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff",
"packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096",
"packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555"
"packages/core/skills/app-shell-patterns/SKILL.md": "f9d8e8f67c1b895e"
}
}
45 changes: 45 additions & 0 deletions docs-src/components/text.docs.examples.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Text } from "@tailor-platform/app-shell";

export function BasicUsage() {
return (
<div className="flex flex-col gap-1">
<Text variant="heading-md">Purchase orders</Text>
<Text className="text-muted-foreground">Orders sent to suppliers this month.</Text>
</div>
);
}

export function Roles() {
return (
<div className="flex flex-col gap-3">
{/* The headings render a div so that this specimen is not a document heading. */}
<Text variant="heading-lg" render={<div />}>
heading-lg: Purchase orders
</Text>
<Text variant="heading-md" render={<div />}>
heading-md: Line items
</Text>
<Text variant="heading-sm" render={<div />}>
heading-sm: Shipping address
</Text>
<Text variant="body-md">body-md: Orders sent to suppliers this month.</Text>
<Text variant="body-sm">body-sm: Updated 2 hours ago</Text>
<Text variant="label-md">label-md: Total amount</Text>
<Text variant="label-sm">label-sm: Draft</Text>
<Text variant="code-sm">code-sm: PO-2026-0001</Text>
</div>
);
}

export function ColorAndModifiers() {
return (
<div className="flex flex-col gap-3">
<Text variant="body-md" className="text-muted-foreground">
Orders sent this month.
</Text>
<Text variant="label-md" className="tabular-nums">
1,240.00
</Text>
</div>
);
}
135 changes: 135 additions & 0 deletions docs-src/components/text.docs.outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
kind: code-backed
group: text
title: Text
description: Text with a typography role, rendered as an element you choose
sources:
- packages/core/src/components/text/**
---

# Text

The `Text` component applies a [typography role](../concepts/styling-theming.md#typography) to its content. A role sets font size, line height, font weight, and letter spacing together. The role does not set the color, and it does not choose the HTML element. Use the `render` prop to choose the element (Base UI render pattern).

## Import

```tsx
import { Text } from "@tailor-platform/app-shell";
```

## Basic Usage

<!-- example: basic-usage -->

<!-- api -->

All standard HTML attributes are also accepted. Put an attribute that belongs to one element, such as `htmlFor` on a `label`, on the `render` element:

```tsx
<Text variant="label-md" render={<label htmlFor="quantity" />}>
Quantity
</Text>
```

Some lint rules report this example; see [Lint](#lint).

## Roles

`variant` takes one of the ten roles. Each role renders a default element when you do not pass `render`:

| `variant` | Default element | Use |
| ----------------- | --------------- | ----------------------------------------------- |
| `heading-lg` | `h2` | Page title (`Layout.Header`) |
| `heading-md` | `h2` | Section heading, card title |
| `heading-sm` | `h2` | Sub-section heading |
| `body-md` | `p` | Body copy, descriptions (default role) |
| `body-sm` | `p` | Caption, timestamp, metadata |
| `body-md-relaxed` | `p` | Long text, such as a chat message or a document |
| `body-sm-relaxed` | `p` | Long text at the small size |
| `label-md` | `span` | Field label, short UI text, figures in a table |
| `label-sm` | `span` | Small label, badge text |
| `code-sm` | `span` | ID and code. `Text` adds `font-mono` for you |

<!-- example: roles -->

The sizes and line heights are in the [Typography section of the styling guide](../concepts/styling-theming.md#typography).

## Heading Level

All three heading roles render `h2` by default. The heading level comes from the document structure, not from the visual size, so set it with `render`:

```tsx
<Text variant="heading-md" render={<h3 />}>
Line items
</Text>
```

To use a heading role for text that is not a heading, render a `div` or a `span`:

```tsx
<Text variant="heading-md" render={<div />}>
1,240 orders
</Text>
```

## Lint

Some `jsx-a11y` rules check that an element has text inside it, for example `heading-has-content` for `<h3 />` and `label-has-associated-control` for `<label />`. These rules report the element in `render` as empty, because they do not see that `Text` passes its children to that element. If your app enables them, disable the rule on that line. ESLint and oxlint both read this comment:

```tsx
// eslint-disable-next-line jsx-a11y/heading-has-content -- Text renders the children
<Text variant="heading-md" render={<h3 />}>
Line items
</Text>
```

## Color and Modifiers

Set the color with a color token in `className`. Four modifiers may also be combined with a role: `leading-none` for single-line text, `tabular-nums`, `font-medium` or `font-semibold` for emphasis, and `uppercase`.

<!-- example: color-and-modifiers -->

```tsx
<Text variant="body-md" className="text-muted-foreground">
Orders sent this month.
</Text>
<Text variant="heading-md" render={<h3 />} className="leading-none">
Card title
</Text>
<Text variant="label-md" className="tabular-nums">
1,240.00
</Text>
```

Do not combine a role with other `leading-*`, `tracking-*`, or `text-*` size values. Pick a different `variant` instead.

## Using Roles Without Text

Each role is also a plain utility, for example `text-heading-md`. Use the utility when you style your own element and do not need `Text`:

```tsx
<th className="text-label-md text-muted-foreground">Amount</th>
```

## TypeScript

```typescript
import { type TextProps } from "@tailor-platform/app-shell";
```

## Accessibility

- A heading role renders a real heading (`h2`) by default, so screen readers list it with the other headings. Set the level with `render` so the levels follow the page structure.
- Do not choose a heading level for its size. `Text` keeps the size and the level separate.
- `body-*` roles render `p`. Inside another `p`, render a `span` instead, because a `p` cannot contain a `p`.

## Palettes

The `cream` and `bloom` palettes set the line height and letter spacing of `h1` to `h6` elements. Under these palettes, a heading role on a heading element uses the palette values (line height 1.2, letter spacing `-0.03em`), including when you add `leading-none`. The same role on a `div` or `span` uses the role values. See the [Typography section of the styling guide](../concepts/styling-theming.md#typography).

## Related Components

- [Card](./card.md) - The table above lists `heading-md` for card titles
- [Layout](./layout.md) - The table above lists `heading-lg` for the page title

AppShell's own components, including these two, still use the stock utilities. They will move to the roles in a later change.
11 changes: 9 additions & 2 deletions docs-src/concepts/styling-theming.docs.outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ Pair a role with a color token from the Color section above. These pairings are
| --------------------------------- | ---------------------------------------------- |
| Page title (`Layout.Header`) | `text-heading-lg` |
| Section heading | `text-heading-md` |
| Card title (`div` or `span`) | `text-heading-md leading-none` |
| Card title | `text-heading-md leading-none` |
| Body copy | `text-body-md` |
| Secondary copy, descriptions | `text-body-md text-muted-foreground` |
| Caption, timestamp, metadata | `text-body-sm text-muted-foreground` |
Expand All @@ -286,12 +286,19 @@ Pair a role with a color token from the Color section above. These pairings are
<span className="text-body-sm text-muted-foreground">Updated 2h ago</span>
```

The [`Text`](../components/text.md) component applies the same roles with a `variant` prop. It renders a default element for each role (`h2` for headings, `p` for body text, `span` for the others), and its `render` prop sets the element, such as the heading level:

```tsx
<Text variant="heading-md" render={<h3 />}>Line items</Text>
<Text variant="body-md" className="text-muted-foreground">Description copy</Text>
```

On an `h1` to `h6` element, the `cream` and `bloom` palettes replace the role's line height and letter spacing, including `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now.

**Rules**

- **A role carries no color.** Set the color with a token such as `text-muted-foreground`.
- **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size.
- **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size. With `Text`, set the element with `render`.
- **A role sets weight and letter spacing on its own element.** It does not inherit them from a parent. Put an emphasis or tracking class on the element that has the role, not on its parent.
- **Four modifiers may be combined with a role.** All are stock Tailwind utilities:
- Compact line height: `leading-none`. Use it only for single-line text, such as a card title. Write it after the role when you merge with `cn()`.
Expand Down
Loading
Loading