diff --git a/.changeset/quiet-roles-align.md b/.changeset/quiet-roles-align.md
new file mode 100644
index 000000000..d96d8707b
--- /dev/null
+++ b/.changeset/quiet-roles-align.md
@@ -0,0 +1,12 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add typography role tokens. A role sets font size, line height, font weight, and letter spacing together. Ten `text-*` utilities are available after you import `@tailor-platform/app-shell/styles`: `text-heading-lg`, `text-heading-md`, `text-heading-sm`, `text-body-md`, `text-body-sm`, `text-label-md`, `text-label-sm`, `text-code-sm`, `text-body-md-relaxed`, and `text-body-sm-relaxed`.
+
+```tsx
+
Purchase orders
+Orders sent to suppliers this month.
+```
+
+Each role reads four CSS variables named `--app-shell-type--`, which you can override on `:root`. AppShell's `cn()` now keeps a role together with a text color class, for example in `className="text-label-md text-muted-foreground"` passed to an AppShell component. If your app has its own `tailwind-merge` setup, see the Typography section of the styling guide.
diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md
new file mode 100644
index 000000000..400702edd
--- /dev/null
+++ b/decisions/typography-roles.md
@@ -0,0 +1,88 @@
+# 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.
+
+## Context
+
+Tracking issue: tailor-inc/platform-planning#1580 ([analysis comment](https://github.com/tailor-inc/platform-planning/issues/1580#issuecomment-5855498547)).
+
+Until now, AppShell defined only the font family (`--font-sans`). `docs/concepts/styling-theming.md` told consumers to compose text from stock Tailwind utilities and gave a table of eight pairings. A measurement of four sources on 2026-09-27 counted how often real code matched that table. The match rate counts only className strings that set a size and at least one more property (weight, line height, or letter spacing):
+
+| Source | Usages | Combinations that match the table |
+| ----------------------------------------------------------------- | ------ | --------------------------------- |
+| AppShell components (`packages/core/src/components`, `ebd92cf`) | 238 | 17% |
+| UI Catalogue pages and patterns (`tailor-inc/app-web`, `a5872bb`) | 321 | 15% |
+| erp-kit scaffold (`tailor-platform/erp-kit`, `a736aeb`) | 528 | 40% |
+| AppShell catalogue folder (AI reference code, `ebd92cf`) | 24 | 50%, from 6 combinations |
+
+The catalogue folder is the AI reference code that has since moved to `docs-src/`. Its sample is too small to compare with the others. The same numbers are in the analysis comment on #1580.
+
+The most common unlisted combination was `text-sm font-medium` (a label). It appears in all four sources. Code also used arbitrary sizes such as `text-[10px]`, and set line height explicitly in about 8 to 11% of places.
+
+The styling guide says "the tokens are the rails". Typography had no tokens, so nothing gave a reader or an AI agent a name to pick.
+
+## Decision
+
+### Tokens are the source of truth
+
+Each role is a named set of four values: size, line height, weight, and letter spacing. A `Text` component can be added later as a thin layer on top. The tokens come first, because consumers, AI agents, and future components all need the same names.
+
+### Two layers, as for colors
+
+- `themes/default.css` holds the values as `--app-shell-type--` on `:root`. A palette or an app can override them. The `--app-shell-` prefix follows the existing `--app-shell-font-sans` override variable and keeps these `:root` variables apart from variables defined by the app or other libraries.
+- `theme.bridge.css` maps them to Tailwind with `--text-` and its `--line-height`, `--font-weight`, and `--letter-spacing` suffixes, inside the existing `@theme inline`.
+
+`@theme inline` writes literal values into the utility. If the values sat in the bridge, they could not be overridden on `:root`. So the bridge holds only `var()` references.
+
+The `--text-*` namespace cannot set `font-family`. `code-sm` therefore requires `font-mono` on the element.
+
+### Ten roles
+
+| Role | Size | Line height | Weight | Letter spacing |
+| ----------------- | ---- | ----------- | ------ | -------------- |
+| `heading-lg` | 24px | 32px | 700 | -0.025em |
+| `heading-md` | 18px | 28px | 600 | 0 |
+| `heading-sm` | 14px | 20px | 600 | 0 |
+| `body-md` | 14px | 20px | 400 | 0 |
+| `body-sm` | 12px | 16px | 400 | 0 |
+| `label-md` | 14px | 20px | 500 | 0 |
+| `label-sm` | 12px | 16px | 500 | 0 |
+| `code-sm` | 12px | 18px | 400 | 0 |
+| `body-md-relaxed` | 14px | 24px | 400 | 0 |
+| `body-sm-relaxed` | 12px | 20px | 400 | 0 |
+
+- Line height is stored as a unitless ratio (`calc(32 / 24)`), so the line box scales with the size. The line boxes are on a 4px grid. `code-sm` uses an 18px line box (ratio 1.5). The current `font-mono text-xs` gives 16px (ratio 1.33). The 2px step is a design choice for monospace text, not a measured result.
+- Every role sets letter spacing, including `0em`, so that no role inherits letter spacing from a parent.
+- Labels keep weight 500, the value used today.
+- The relaxed variants exist for body text only. They are for long text such as chat messages and documents.
+- `overline` is not included. It is planned as a scoped role in a later change.
+
+### Modifiers
+
+Four stock Tailwind utilities may be combined with a role: `leading-none` (compact, single-line text only), `tabular-nums`, `font-medium` or `font-semibold` for emphasis on inline text, and `uppercase`. This is a documented rule and has no code. Other `leading-*`, `tracking-*`, and `text-[Npx]` values are not combined with a role. When a role and `leading-none` are merged with `cn()`, write `leading-none` after the role; a size class removes an earlier `leading-*` class in `tailwind-merge`, the same as for a stock size such as `text-lg`.
+
+A stock `leading-*`, `font-*`, or `tracking-*` on the same element still overrides the role value, because the emitted rule reads `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` first.
+
+### Other rules
+
+- 12px is the minimum size. Existing 10px and 11px text moves to a 12px role in a separate change.
+- A role does not choose the HTML element. The element comes from the document structure.
+- A role carries no color.
+- The card title direction is `heading-md` with `leading-none`. It is documented in the pairing table only. No component changes in this PR.
+
+### `cn()` knows the role names
+
+`tailwind-merge` reads an unknown `text-*` value as a text color. Without a change, `cn("text-label-md", "text-muted-foreground")` keeps only the color. AppShell components pass a consumer `className` through `cn()` today, so `` would lose the role even though nothing in core uses roles yet (checked with `tailwind-merge` on `"astw:flex astw:text-sm"` merged with `"text-label-md text-muted-foreground"`: the role is removed). Core's `cn()` therefore registers the ten role names in the `font-size` group. The documentation shows the same setting for consumers who have their own `cn()`.
+
+This registration is a deviation from the first plan, which left `cn()` for the change that applies roles to components. It is kept in this PR because of the case above. The change is limited to `packages/core/src/lib/utils.ts` and its test, and can be reverted on its own.
+
+Limit: this does not let a role replace a size that core sets on its own element. Core classes carry the `astw:` prefix, and `tailwind-merge` reads that prefix as a variant, so `cn("astw:text-sm", "text-label-md")` keeps both classes. The same limit applies today to any unprefixed `text-*` class. Which of the two wins then depends on CSS order. Overriding an `astw:text-*` size from a consumer `className` is not supported; it needs the follow-up change that applies roles in core components. The role list in `utils.ts` and the roles in `default.css` must stay in sync; `utils.test.ts` checks this.
+
+## Consequences and open questions
+
+- **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.
+- **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.
diff --git a/docs-browser/src/index.css b/docs-browser/src/index.css
index e1e5a597b..21b7f6a16 100644
--- a/docs-browser/src/index.css
+++ b/docs-browser/src/index.css
@@ -2,10 +2,10 @@
@import "@tailor-platform/app-shell/styles";
@import "@tailor-platform/app-shell/themes/bloom";
-/* The live examples live at the repo root (docs/), outside this app's own tree,
+/* The live examples live at the repo root (docs-src/), outside this app's own tree,
so Tailwind won't discover their utility classes by default. Point it at them
explicitly so classes like `flex gap-2` used in examples get generated. */
-@source "../../../docs/**/*.examples.tsx";
+@source "../../docs-src/**/*.docs.examples.tsx";
html,
body {
diff --git a/docs-manifest.json b/docs-manifest.json
index ff7b8e2ab..163a15912 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -1287,10 +1287,10 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "a16d07a7eae92139",
+ "outline": "e63d837190e71ff1",
"snapshot": null,
- "outputMd": "9ee8eba5945a9db9",
- "examples": "d3cca347b055a503"
+ "outputMd": "112b43c697d30369",
+ "examples": "4d19f4f688e0f0ac"
}
},
"table": {
@@ -1659,7 +1659,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": "dc6b3afc049b4148",
- "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "9ee8eba5945a9db9",
+ "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "112b43c697d30369",
"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",
diff --git a/docs-src/concepts/styling-theming.docs.examples.tsx b/docs-src/concepts/styling-theming.docs.examples.tsx
index 7e4dfdec3..8a1444819 100644
--- a/docs-src/concepts/styling-theming.docs.examples.tsx
+++ b/docs-src/concepts/styling-theming.docs.examples.tsx
@@ -12,3 +12,99 @@ export function ThemeToggle() {
export function AppearanceToggle() {
return ;
}
+
+export function TypographyRoles() {
+ const rows = [
+ {
+ role: "heading-lg",
+ className: "text-heading-lg",
+ en: "Purchase orders",
+ ja: "発注管理",
+ },
+ {
+ role: "heading-md",
+ className: "text-heading-md",
+ en: "Purchase orders by supplier",
+ ja: "仕入先別の発注一覧",
+ },
+ {
+ role: "heading-sm",
+ className: "text-heading-sm",
+ en: "Delivery address",
+ ja: "納品先",
+ },
+ {
+ role: "body-md",
+ className: "text-body-md",
+ en: "The order was sent to the supplier on 12 March.",
+ ja: "発注書は3月12日に仕入先へ送付されました。",
+ },
+ {
+ role: "body-sm",
+ className: "text-body-sm text-muted-foreground",
+ en: "Updated 2 hours ago",
+ ja: "2時間前に更新",
+ },
+ {
+ role: "label-md",
+ className: "text-label-md",
+ en: "Order quantity",
+ ja: "発注数量",
+ },
+ {
+ role: "label-sm",
+ className: "text-label-sm",
+ en: "Draft",
+ ja: "下書き",
+ },
+ {
+ role: "code-sm",
+ className: "font-mono text-code-sm",
+ en: "PO-2026-000184",
+ ja: "PO-2026-000184",
+ },
+ {
+ role: "body-md-relaxed",
+ className: "text-body-md-relaxed",
+ compareWith: { role: "body-md", className: "text-body-md" },
+ en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.",
+ ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。",
+ },
+ {
+ role: "body-sm-relaxed",
+ className: "text-body-sm-relaxed text-muted-foreground",
+ compareWith: { role: "body-sm", className: "text-body-sm text-muted-foreground" },
+ en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.",
+ ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。",
+ },
+ ];
+
+ return (
+
+ {rows.map(({ role, className, compareWith, en, ja }) => (
+
+
{role}
+ {compareWith ? (
+ // A relaxed role changes only line height, so show the same text in the base role first.
+
+ {[compareWith, { role, className }].map((variant) => (
+
+
+ {variant.role}
+
+
{en}
+
{ja}
+
+ ))}
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md
index fefc8f1ed..c2d5d721e 100644
--- a/docs-src/concepts/styling-theming.docs.outline.md
+++ b/docs-src/concepts/styling-theming.docs.outline.md
@@ -246,29 +246,105 @@ Hand-typing `padding: 13px` is a smell. Round to the nearest scale step; if noth
### Typography
-**AppShell defines no typography scale tokens.** There is no `text-h1`, `text-body`, or `text-caption`. The only typography token is `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`.
-
-Compose roles from stock Tailwind utilities. These pairings are what AppShell's own components use — match them so your screens sit consistently alongside the primitives:
-
-| Role | Utilities |
-| --------------------------------- | ----------------------------------------- |
-| Page title (`Layout.Header`) | `text-2xl font-bold tracking-tight` |
-| Section heading | `text-lg font-semibold` |
-| Card title | `text-lg font-semibold leading-none` |
-| Body copy | `text-sm` |
-| Secondary copy, descriptions | `text-sm text-muted-foreground` |
-| Caption, timestamp, metadata | `text-xs text-muted-foreground` |
-| Numeric value in a table or field | `text-sm font-medium tabular-nums` |
-| ID, code, keyboard hint | `font-mono text-xs text-muted-foreground` |
+AppShell defines **composite typography role tokens**. A role sets font size, line height, font weight, and letter spacing together, so one utility such as `text-heading-md` replaces a set like `text-lg font-semibold`. The font family is a separate token, `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`.
+
+There are 8 core roles and 2 relaxed body variants:
+
+| Role | Utility | Size / line height | Weight | Use |
+| ----------------- | ---------------------- | ------------------ | ------ | ----------------------------------------------- |
+| `heading-lg` | `text-heading-lg` | 24px / 32px | 700 | Page title (`Layout.Header`) |
+| `heading-md` | `text-heading-md` | 18px / 28px | 600 | Section heading, card title |
+| `heading-sm` | `text-heading-sm` | 14px / 20px | 600 | Sub-section heading |
+| `body-md` | `text-body-md` | 14px / 20px | 400 | Body copy, descriptions |
+| `body-sm` | `text-body-sm` | 12px / 16px | 400 | Caption, timestamp, metadata |
+| `label-md` | `text-label-md` | 14px / 20px | 500 | Field label, short UI text, figures in a table |
+| `label-sm` | `text-label-sm` | 12px / 16px | 500 | Small label, badge text |
+| `code-sm` | `text-code-sm` | 12px / 18px | 400 | ID and code. Use with `font-mono` |
+| `body-md-relaxed` | `text-body-md-relaxed` | 14px / 24px | 400 | Long text, such as a chat message or a document |
+| `body-sm-relaxed` | `text-body-sm-relaxed` | 12px / 20px | 400 | Long text at the small size |
+
+`heading-lg` also sets letter spacing to `-0.025em`. All other roles set `0em`.
+
+Pair a role with a color token from the Color section above. These pairings are the recommended combinations:
+
+| Use | Utilities |
+| --------------------------------- | ---------------------------------------------- |
+| Page title (`Layout.Header`) | `text-heading-lg` |
+| Section heading | `text-heading-md` |
+| Card title (`div` or `span`) | `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` |
+| Numeric value in a table or field | `text-label-md tabular-nums` |
+| ID, code | `font-mono text-code-sm text-muted-foreground` |
+
+
```tsx
-Section
-Description copy
-Updated 2h ago
+Section
+Description copy
+Updated 2h ago
```
+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 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()`.
+ - Tabular figures: `tabular-nums`.
+ - Emphasis on inline text: `font-medium` or `font-semibold`.
+ - Uppercase: `uppercase`.
+- **Do not combine a role with other `leading-*`, `tracking-*`, or `text-[Npx]` values.** If none of the roles fits, use the nearest role.
+- **12px is the minimum size.** Do not use smaller text.
+- **`code-sm` needs `font-mono`.** The role sets size, line height, weight, and letter spacing, but a role cannot set the font family.
+- **Use the relaxed variants for long text only.** For labels, table cells, and other short text, use the regular roles.
+
Always use `tabular-nums` for numbers that stack in a column — without it, digits jitter between rows.
+**Change a role.** Each role is four CSS variables named `--app-shell-type--`. Override them on `:root` to change a role for the whole app. Typography has no dark variant, so one override applies to light and dark mode.
+
+```css
+:root {
+ --app-shell-type-heading-lg-size: 1.75rem;
+ --app-shell-type-heading-lg-line-height: calc(36 / 28);
+}
+```
+
+The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility, and over any `leading-*` or `tracking-*` class, on those elements. Under these palettes, an `h1` to `h6` element takes its line height (1.2) and letter spacing (`-0.03em`) from the palette. This includes `leading-none`: `` has a line height of 1.2, while a `div` with the same classes has 1.0. Font size and weight still come from the role. The `default` palette has no such rule.
+
+**`tailwind-merge`.** AppShell's `cn()` keeps a role together with a text color class, for example when you pass `className="text-label-md text-muted-foreground"` to an AppShell component. It cannot replace a size that a component sets itself, because component classes carry the internal `astw:` prefix (see "Why a plain utility can't override an AppShell default" below). If your app has its own `cn()` built on `tailwind-merge`, register the role names. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color.
+
+```ts
+import { extendTailwindMerge } from "tailwind-merge";
+
+const twMerge = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ "font-size": [
+ {
+ text: [
+ "heading-lg",
+ "heading-md",
+ "heading-sm",
+ "body-md",
+ "body-sm",
+ "body-md-relaxed",
+ "body-sm-relaxed",
+ "label-md",
+ "label-sm",
+ "code-sm",
+ ],
+ },
+ ],
+ },
+ },
+});
+```
+
### Radius
`--radius` (`0.625rem`) is the base; the bridge derives four steps from it. Pick by component role — a card is always `md`, regardless of its size on screen.
@@ -335,12 +411,12 @@ Never invent a z value — `z-index: 9999` is always wrong. Popups and overlays
**AppShell exports no `Icon` component and defines no `--icon-*` tokens.** Icons come from [`lucide-react`](https://lucide.dev), which AppShell already depends on. Size them with Tailwind, pairing icon size to the adjacent text:
-| Text size | Icon class |
-| ------------------ | ---------- |
-| `text-xs` | `size-3` |
-| `text-sm` | `size-4` |
-| `text-lg` | `size-5` |
-| `text-2xl` (title) | `size-6` |
+| Text size | Icon class |
+| ------------------------------------------------------------------- | ---------- |
+| 12px: `text-xs`, `text-body-sm`, `text-label-sm`, `text-code-sm` | `size-3` |
+| 14px: `text-sm`, `text-body-md`, `text-label-md`, `text-heading-sm` | `size-4` |
+| 18px: `text-lg`, `text-heading-md` | `size-5` |
+| 24px (title): `text-2xl`, `text-heading-lg` | `size-6` |
```tsx
import { Check } from "lucide-react";
@@ -590,5 +666,5 @@ These are visual-composition rules every screen must follow, regardless of patte
| Hover / focus transition | `duration-150 ease-out` |
| State change (toggle, select) | `duration-200 ease-in-out` |
| Two-column detail at <1024 | right column collapses below main — do not override |
-| Inline ID, code, table number | `font-mono text-xs` (identifiers) / `tabular-nums` (figures) |
-| Timestamp, label, subtle metadata | `text-xs text-muted-foreground` |
+| Inline ID, code, table number | `font-mono text-code-sm` (identifiers) / `text-label-md tabular-nums` (figures) |
+| Timestamp, label, subtle metadata | `text-body-sm text-muted-foreground` |
diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md
index 9bafa86d3..61a21f8dc 100644
--- a/docs/concepts/styling-theming.md
+++ b/docs/concepts/styling-theming.md
@@ -263,29 +263,203 @@ Hand-typing `padding: 13px` is a smell. Round to the nearest scale step; if noth
### Typography
-**AppShell defines no typography scale tokens.** There is no `text-h1`, `text-body`, or `text-caption`. The only typography token is `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`.
+AppShell defines **composite typography role tokens**. A role sets font size, line height, font weight, and letter spacing together, so one utility such as `text-heading-md` replaces a set like `text-lg font-semibold`. The font family is a separate token, `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`.
+
+There are 8 core roles and 2 relaxed body variants:
+
+| Role | Utility | Size / line height | Weight | Use |
+| ----------------- | ---------------------- | ------------------ | ------ | ----------------------------------------------- |
+| `heading-lg` | `text-heading-lg` | 24px / 32px | 700 | Page title (`Layout.Header`) |
+| `heading-md` | `text-heading-md` | 18px / 28px | 600 | Section heading, card title |
+| `heading-sm` | `text-heading-sm` | 14px / 20px | 600 | Sub-section heading |
+| `body-md` | `text-body-md` | 14px / 20px | 400 | Body copy, descriptions |
+| `body-sm` | `text-body-sm` | 12px / 16px | 400 | Caption, timestamp, metadata |
+| `label-md` | `text-label-md` | 14px / 20px | 500 | Field label, short UI text, figures in a table |
+| `label-sm` | `text-label-sm` | 12px / 16px | 500 | Small label, badge text |
+| `code-sm` | `text-code-sm` | 12px / 18px | 400 | ID and code. Use with `font-mono` |
+| `body-md-relaxed` | `text-body-md-relaxed` | 14px / 24px | 400 | Long text, such as a chat message or a document |
+| `body-sm-relaxed` | `text-body-sm-relaxed` | 12px / 20px | 400 | Long text at the small size |
+
+`heading-lg` also sets letter spacing to `-0.025em`. All other roles set `0em`.
+
+Pair a role with a color token from the Color section above. These pairings are the recommended combinations:
+
+| Use | Utilities |
+| --------------------------------- | ---------------------------------------------- |
+| Page title (`Layout.Header`) | `text-heading-lg` |
+| Section heading | `text-heading-md` |
+| Card title (`div` or `span`) | `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` |
+| Numeric value in a table or field | `text-label-md tabular-nums` |
+| ID, code | `font-mono text-code-sm text-muted-foreground` |
+
+
-Compose roles from stock Tailwind utilities. These pairings are what AppShell's own components use — match them so your screens sit consistently alongside the primitives:
+```tsx
+function TypographyRoles() {
+ const rows = [
+ {
+ role: "heading-lg",
+ className: "text-heading-lg",
+ en: "Purchase orders",
+ ja: "発注管理",
+ },
+ {
+ role: "heading-md",
+ className: "text-heading-md",
+ en: "Purchase orders by supplier",
+ ja: "仕入先別の発注一覧",
+ },
+ {
+ role: "heading-sm",
+ className: "text-heading-sm",
+ en: "Delivery address",
+ ja: "納品先",
+ },
+ {
+ role: "body-md",
+ className: "text-body-md",
+ en: "The order was sent to the supplier on 12 March.",
+ ja: "発注書は3月12日に仕入先へ送付されました。",
+ },
+ {
+ role: "body-sm",
+ className: "text-body-sm text-muted-foreground",
+ en: "Updated 2 hours ago",
+ ja: "2時間前に更新",
+ },
+ {
+ role: "label-md",
+ className: "text-label-md",
+ en: "Order quantity",
+ ja: "発注数量",
+ },
+ {
+ role: "label-sm",
+ className: "text-label-sm",
+ en: "Draft",
+ ja: "下書き",
+ },
+ {
+ role: "code-sm",
+ className: "font-mono text-code-sm",
+ en: "PO-2026-000184",
+ ja: "PO-2026-000184",
+ },
+ {
+ role: "body-md-relaxed",
+ className: "text-body-md-relaxed",
+ compareWith: { role: "body-md", className: "text-body-md" },
+ en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.",
+ ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。",
+ },
+ {
+ role: "body-sm-relaxed",
+ className: "text-body-sm-relaxed text-muted-foreground",
+ compareWith: { role: "body-sm", className: "text-body-sm text-muted-foreground" },
+ en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.",
+ ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。",
+ },
+ ];
-| Role | Utilities |
-| --------------------------------- | ----------------------------------------- |
-| Page title (`Layout.Header`) | `text-2xl font-bold tracking-tight` |
-| Section heading | `text-lg font-semibold` |
-| Card title | `text-lg font-semibold leading-none` |
-| Body copy | `text-sm` |
-| Secondary copy, descriptions | `text-sm text-muted-foreground` |
-| Caption, timestamp, metadata | `text-xs text-muted-foreground` |
-| Numeric value in a table or field | `text-sm font-medium tabular-nums` |
-| ID, code, keyboard hint | `font-mono text-xs text-muted-foreground` |
+ return (
+
+ {rows.map(({ role, className, compareWith, en, ja }) => (
+
+
{role}
+ {compareWith ? (
+ // A relaxed role changes only line height, so show the same text in the base role first.
+
+ {[compareWith, { role, className }].map((variant) => (
+
+
+ {variant.role}
+
+
{en}
+
{ja}
+
+ ))}
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
+```
```tsx
-Section
-
Description copy
-Updated 2h ago
+Section
+Description copy
+Updated 2h ago
```
+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 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()`.
+ - Tabular figures: `tabular-nums`.
+ - Emphasis on inline text: `font-medium` or `font-semibold`.
+ - Uppercase: `uppercase`.
+- **Do not combine a role with other `leading-*`, `tracking-*`, or `text-[Npx]` values.** If none of the roles fits, use the nearest role.
+- **12px is the minimum size.** Do not use smaller text.
+- **`code-sm` needs `font-mono`.** The role sets size, line height, weight, and letter spacing, but a role cannot set the font family.
+- **Use the relaxed variants for long text only.** For labels, table cells, and other short text, use the regular roles.
+
Always use `tabular-nums` for numbers that stack in a column — without it, digits jitter between rows.
+**Change a role.** Each role is four CSS variables named `--app-shell-type--`. Override them on `:root` to change a role for the whole app. Typography has no dark variant, so one override applies to light and dark mode.
+
+```css
+:root {
+ --app-shell-type-heading-lg-size: 1.75rem;
+ --app-shell-type-heading-lg-line-height: calc(36 / 28);
+}
+```
+
+The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility, and over any `leading-*` or `tracking-*` class, on those elements. Under these palettes, an `h1` to `h6` element takes its line height (1.2) and letter spacing (`-0.03em`) from the palette. This includes `leading-none`: `` has a line height of 1.2, while a `div` with the same classes has 1.0. Font size and weight still come from the role. The `default` palette has no such rule.
+
+**`tailwind-merge`.** AppShell's `cn()` keeps a role together with a text color class, for example when you pass `className="text-label-md text-muted-foreground"` to an AppShell component. It cannot replace a size that a component sets itself, because component classes carry the internal `astw:` prefix (see "Why a plain utility can't override an AppShell default" below). If your app has its own `cn()` built on `tailwind-merge`, register the role names. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color.
+
+```ts
+import { extendTailwindMerge } from "tailwind-merge";
+
+const twMerge = extendTailwindMerge({
+ extend: {
+ classGroups: {
+ "font-size": [
+ {
+ text: [
+ "heading-lg",
+ "heading-md",
+ "heading-sm",
+ "body-md",
+ "body-sm",
+ "body-md-relaxed",
+ "body-sm-relaxed",
+ "label-md",
+ "label-sm",
+ "code-sm",
+ ],
+ },
+ ],
+ },
+ },
+});
+```
+
### Radius
`--radius` (`0.625rem`) is the base; the bridge derives four steps from it. Pick by component role — a card is always `md`, regardless of its size on screen.
@@ -352,12 +526,12 @@ Never invent a z value — `z-index: 9999` is always wrong. Popups and overlays
**AppShell exports no `Icon` component and defines no `--icon-*` tokens.** Icons come from [`lucide-react`](https://lucide.dev), which AppShell already depends on. Size them with Tailwind, pairing icon size to the adjacent text:
-| Text size | Icon class |
-| ------------------ | ---------- |
-| `text-xs` | `size-3` |
-| `text-sm` | `size-4` |
-| `text-lg` | `size-5` |
-| `text-2xl` (title) | `size-6` |
+| Text size | Icon class |
+| ------------------------------------------------------------------- | ---------- |
+| 12px: `text-xs`, `text-body-sm`, `text-label-sm`, `text-code-sm` | `size-3` |
+| 14px: `text-sm`, `text-body-md`, `text-label-md`, `text-heading-sm` | `size-4` |
+| 18px: `text-lg`, `text-heading-md` | `size-5` |
+| 24px (title): `text-2xl`, `text-heading-lg` | `size-6` |
```tsx
import { Check } from "lucide-react";
@@ -607,5 +781,5 @@ These are visual-composition rules every screen must follow, regardless of patte
| Hover / focus transition | `duration-150 ease-out` |
| State change (toggle, select) | `duration-200 ease-in-out` |
| Two-column detail at <1024 | right column collapses below main — do not override |
-| Inline ID, code, table number | `font-mono text-xs` (identifiers) / `tabular-nums` (figures) |
-| Timestamp, label, subtle metadata | `text-xs text-muted-foreground` |
+| Inline ID, code, table number | `font-mono text-code-sm` (identifiers) / `text-label-md tabular-nums` (figures) |
+| Timestamp, label, subtle metadata | `text-body-sm text-muted-foreground` |
diff --git a/packages/core/src/assets/theme-bridge.test.ts b/packages/core/src/assets/theme-bridge.test.ts
index 084b4e356..86fb5ef0c 100644
--- a/packages/core/src/assets/theme-bridge.test.ts
+++ b/packages/core/src/assets/theme-bridge.test.ts
@@ -68,4 +68,92 @@ describe("theme bridge", () => {
// 5 intents x 8 roles, pinned for the same reason as the alert count.
expect(bridgedTokens(SEMANTIC_PREFIX).size).toBe(40);
});
+
+ describe("typography roles", () => {
+ const slots = ["size", "line-height", "weight", "letter-spacing"] as const;
+ /** `--app-shell-type--` variables in the default palette, grouped by role. */
+ const defined = new Map>();
+ for (const m of defaultTheme.matchAll(
+ /^\s*--app-shell-type-([a-z-]+)-(size|line-height|weight|letter-spacing):\s*([^;]+);/gm,
+ )) {
+ const role = m[1];
+ defined.set(role, (defined.get(role) ?? new Map()).set(m[2], m[3].trim()));
+ }
+ /** Bridge entries `--text-[--]: var(--app-shell-type-...)`, keyed by property name. */
+ const bridgedText = new Map(
+ [...bridge.matchAll(/^\s*(--text-[a-z-]+):\s*var\((--app-shell-type-[a-z-]+)\);/gm)].map(
+ (m) => [m[1], m[2]],
+ ),
+ );
+
+ it("pins the role count at 10", () => {
+ // Dropping a whole role from both files would still pass the symmetry checks.
+ expect(defined.size).toBe(10);
+ });
+
+ it("defines exactly four variables per role", () => {
+ for (const [role, values] of defined) {
+ expect([...values.keys()].toSorted(), role).toEqual([...slots].toSorted());
+ }
+ });
+
+ it("bridges every role slot to its own variable", () => {
+ const suffix = {
+ size: "",
+ "line-height": "--line-height",
+ weight: "--font-weight",
+ "letter-spacing": "--letter-spacing",
+ } as const;
+ const expected = new Map();
+ for (const role of defined.keys()) {
+ for (const slot of slots) {
+ expected.set(`--text-${role}${suffix[slot]}`, `--app-shell-type-${role}-${slot}`);
+ }
+ }
+ expect(bridgedText).toEqual(expected);
+ });
+
+ it("does not collide with a --color-* token that Tailwind resolves first", () => {
+ // `text-` resolves against --color- before --text-.
+ const colors = [...bridge.matchAll(/^\s*--color-([a-z0-9-]+):/gm)].map((m) => m[1]);
+ for (const role of defined.keys()) {
+ expect(colors, role).not.toContain(role);
+ }
+ for (const name of colors) {
+ expect(name).not.toMatch(/^(heading|body|label|code)(-|$)/);
+ }
+ });
+
+ it("keeps the line boxes on the spec values", () => {
+ // size (px) x line-height ratio = line box (px); weight and letter spacing are pinned too. Values from decisions/typography-roles.md.
+ const spec: Record<
+ string,
+ [size: number, lineBox: number, weight: number, letterSpacing: string]
+ > = {
+ "heading-lg": [24, 32, 700, "-0.025em"],
+ "heading-md": [18, 28, 600, "0em"],
+ "heading-sm": [14, 20, 600, "0em"],
+ "body-md": [14, 20, 400, "0em"],
+ "body-sm": [12, 16, 400, "0em"],
+ "label-md": [14, 20, 500, "0em"],
+ "label-sm": [12, 16, 500, "0em"],
+ "code-sm": [12, 18, 400, "0em"],
+ "body-md-relaxed": [14, 24, 400, "0em"],
+ "body-sm-relaxed": [12, 20, 400, "0em"],
+ };
+ expect([...defined.keys()].toSorted()).toEqual(Object.keys(spec).toSorted());
+ for (const [role, [size, lineBox, weight, letterSpacing]] of Object.entries(spec)) {
+ const values = defined.get(role)!;
+ const rem = /^([0-9.]+)rem$/.exec(values.get("size") ?? "");
+ expect(rem, `${role} size`).not.toBeNull();
+ expect(Number(rem![1]) * 16, `${role} size`).toBe(size);
+ const ratio = /^calc\((\d+) \/ (\d+)\)$/.exec(values.get("line-height") ?? "");
+ expect(ratio, `${role} line-height`).not.toBeNull();
+ expect(Number(ratio![2]), `${role} ratio base`).toBe(size);
+ expect(Number(ratio![1]), `${role} line box`).toBe(lineBox);
+ expect(Number(values.get("weight")), `${role} weight`).toBe(weight);
+ expect(values.get("letter-spacing"), `${role} letter-spacing`).toBe(letterSpacing);
+ }
+ });
+ });
});
diff --git a/packages/core/src/assets/theme.bridge.css b/packages/core/src/assets/theme.bridge.css
index 5500ba076..31d9cc0b5 100644
--- a/packages/core/src/assets/theme.bridge.css
+++ b/packages/core/src/assets/theme.bridge.css
@@ -36,6 +36,56 @@
sans-serif
);
+ /*
+ * Typography roles. `text-heading-lg` sets font-size, line-height, font-weight and
+ * letter-spacing together. The values live in themes/default.css and are referenced
+ * with `var()` here, like the colors: `@theme inline` writes literals into the
+ * utility, so a value placed here could not be overridden on `:root`.
+ *
+ * The `--text-*` namespace cannot set font-family. `text-code-sm` is used with
+ * `font-mono`.
+ */
+ --text-heading-lg: var(--app-shell-type-heading-lg-size);
+ --text-heading-lg--line-height: var(--app-shell-type-heading-lg-line-height);
+ --text-heading-lg--font-weight: var(--app-shell-type-heading-lg-weight);
+ --text-heading-lg--letter-spacing: var(--app-shell-type-heading-lg-letter-spacing);
+ --text-heading-md: var(--app-shell-type-heading-md-size);
+ --text-heading-md--line-height: var(--app-shell-type-heading-md-line-height);
+ --text-heading-md--font-weight: var(--app-shell-type-heading-md-weight);
+ --text-heading-md--letter-spacing: var(--app-shell-type-heading-md-letter-spacing);
+ --text-heading-sm: var(--app-shell-type-heading-sm-size);
+ --text-heading-sm--line-height: var(--app-shell-type-heading-sm-line-height);
+ --text-heading-sm--font-weight: var(--app-shell-type-heading-sm-weight);
+ --text-heading-sm--letter-spacing: var(--app-shell-type-heading-sm-letter-spacing);
+ --text-body-md: var(--app-shell-type-body-md-size);
+ --text-body-md--line-height: var(--app-shell-type-body-md-line-height);
+ --text-body-md--font-weight: var(--app-shell-type-body-md-weight);
+ --text-body-md--letter-spacing: var(--app-shell-type-body-md-letter-spacing);
+ --text-body-sm: var(--app-shell-type-body-sm-size);
+ --text-body-sm--line-height: var(--app-shell-type-body-sm-line-height);
+ --text-body-sm--font-weight: var(--app-shell-type-body-sm-weight);
+ --text-body-sm--letter-spacing: var(--app-shell-type-body-sm-letter-spacing);
+ --text-label-md: var(--app-shell-type-label-md-size);
+ --text-label-md--line-height: var(--app-shell-type-label-md-line-height);
+ --text-label-md--font-weight: var(--app-shell-type-label-md-weight);
+ --text-label-md--letter-spacing: var(--app-shell-type-label-md-letter-spacing);
+ --text-label-sm: var(--app-shell-type-label-sm-size);
+ --text-label-sm--line-height: var(--app-shell-type-label-sm-line-height);
+ --text-label-sm--font-weight: var(--app-shell-type-label-sm-weight);
+ --text-label-sm--letter-spacing: var(--app-shell-type-label-sm-letter-spacing);
+ --text-code-sm: var(--app-shell-type-code-sm-size);
+ --text-code-sm--line-height: var(--app-shell-type-code-sm-line-height);
+ --text-code-sm--font-weight: var(--app-shell-type-code-sm-weight);
+ --text-code-sm--letter-spacing: var(--app-shell-type-code-sm-letter-spacing);
+ --text-body-md-relaxed: var(--app-shell-type-body-md-relaxed-size);
+ --text-body-md-relaxed--line-height: var(--app-shell-type-body-md-relaxed-line-height);
+ --text-body-md-relaxed--font-weight: var(--app-shell-type-body-md-relaxed-weight);
+ --text-body-md-relaxed--letter-spacing: var(--app-shell-type-body-md-relaxed-letter-spacing);
+ --text-body-sm-relaxed: var(--app-shell-type-body-sm-relaxed-size);
+ --text-body-sm-relaxed--line-height: var(--app-shell-type-body-sm-relaxed-line-height);
+ --text-body-sm-relaxed--font-weight: var(--app-shell-type-body-sm-relaxed-weight);
+ --text-body-sm-relaxed--letter-spacing: var(--app-shell-type-body-sm-relaxed-letter-spacing);
+
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css
index 97899dee8..fac3798e7 100644
--- a/packages/core/src/assets/theme.css
+++ b/packages/core/src/assets/theme.css
@@ -16,9 +16,12 @@
* 4. PALETTE — optional: radius, charts, shadows, destructive.
*
* Inherited from default.css — do not duplicate in new palettes:
- * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-*.
+ * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-*,
+ * --app-shell-type-* (typography roles).
* The colour roles --{intent}-{role} inherit too; a palette overrides them
- * only to recolour the status components.
+ * only to recolour the status components. Typography roles are inherited
+ * the same way: do not copy the defaults, override only the variables
+ * whose values differ.
*
* Light/dark mode is controlled separately via `.dark` on .
* Palette selection is by CSS import; there is no runtime palette prop.
diff --git a/packages/core/src/assets/themes/_template.css b/packages/core/src/assets/themes/_template.css
index 6783295fa..a4dd7e390 100644
--- a/packages/core/src/assets/themes/_template.css
+++ b/packages/core/src/assets/themes/_template.css
@@ -17,10 +17,13 @@
* `@tailor-platform/app-shell/styles` in the consuming app.
*
* DO NOT SET (inherited from `:root` in default.css):
- * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-*
+ * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-*,
+ * --app-shell-type-*
* The colour roles --{info|success|warning|danger|neutral}-{role} also inherit.
* Override a role only to recolour the status components (Badge, Alert, ...)
* for this palette; never set --status-* or --alert-*, they alias the roles.
+ * Typography roles (--app-shell-type-*) inherit too: do not copy the defaults,
+ * override only the variables whose values differ.
*
* PREVIEW: `/showcase/colors` in the Vite example app.
* ═══════════════════════════════════════════════════════════════════════════
diff --git a/packages/core/src/assets/themes/default.css b/packages/core/src/assets/themes/default.css
index 849f012b5..0a76bc497 100644
--- a/packages/core/src/assets/themes/default.css
+++ b/packages/core/src/assets/themes/default.css
@@ -56,9 +56,10 @@
}
/*
- * Dark variant. Overrides BRAND + SYSTEM + PALETTE. The primitive scales (6)
- * and the semantic roles (7) follow below. Shared aliases/ring re-resolve
- * when BRAND changes; `--radius` inherits from the light block.
+ * Dark variant. Overrides BRAND + SYSTEM + PALETTE. The primitive scales (6),
+ * the semantic roles (7) and the typography roles (8) follow below. Shared
+ * aliases/ring re-resolve when BRAND changes; `--radius` inherits from the
+ * light block.
*/
.dark {
color-scheme: dark;
@@ -428,3 +429,54 @@
/* Warning text: step 12 on the light surface, step 11 on the dark one. */
--warning-text: var(--primitive-amber-11);
}
+
+/*
+ * 8. TYPOGRAPHY — composite roles; light only, no dark variant.
+ *
+ * Four variables per role: size, line-height (unitless ratio), weight and
+ * letter-spacing. theme.bridge.css maps them to `text-` utilities.
+ * Palettes inherit these; override `--app-shell-type-*` on `:root` to change
+ * a role. See decisions/typography-roles.md.
+ */
+:root {
+ --app-shell-type-heading-lg-size: 1.5rem;
+ --app-shell-type-heading-lg-line-height: calc(32 / 24);
+ --app-shell-type-heading-lg-weight: 700;
+ --app-shell-type-heading-lg-letter-spacing: -0.025em;
+ --app-shell-type-heading-md-size: 1.125rem;
+ --app-shell-type-heading-md-line-height: calc(28 / 18);
+ --app-shell-type-heading-md-weight: 600;
+ --app-shell-type-heading-md-letter-spacing: 0em;
+ --app-shell-type-heading-sm-size: 0.875rem;
+ --app-shell-type-heading-sm-line-height: calc(20 / 14);
+ --app-shell-type-heading-sm-weight: 600;
+ --app-shell-type-heading-sm-letter-spacing: 0em;
+ --app-shell-type-body-md-size: 0.875rem;
+ --app-shell-type-body-md-line-height: calc(20 / 14);
+ --app-shell-type-body-md-weight: 400;
+ --app-shell-type-body-md-letter-spacing: 0em;
+ --app-shell-type-body-sm-size: 0.75rem;
+ --app-shell-type-body-sm-line-height: calc(16 / 12);
+ --app-shell-type-body-sm-weight: 400;
+ --app-shell-type-body-sm-letter-spacing: 0em;
+ --app-shell-type-label-md-size: 0.875rem;
+ --app-shell-type-label-md-line-height: calc(20 / 14);
+ --app-shell-type-label-md-weight: 500;
+ --app-shell-type-label-md-letter-spacing: 0em;
+ --app-shell-type-label-sm-size: 0.75rem;
+ --app-shell-type-label-sm-line-height: calc(16 / 12);
+ --app-shell-type-label-sm-weight: 500;
+ --app-shell-type-label-sm-letter-spacing: 0em;
+ --app-shell-type-code-sm-size: 0.75rem;
+ --app-shell-type-code-sm-line-height: calc(18 / 12);
+ --app-shell-type-code-sm-weight: 400;
+ --app-shell-type-code-sm-letter-spacing: 0em;
+ --app-shell-type-body-md-relaxed-size: 0.875rem;
+ --app-shell-type-body-md-relaxed-line-height: calc(24 / 14);
+ --app-shell-type-body-md-relaxed-weight: 400;
+ --app-shell-type-body-md-relaxed-letter-spacing: 0em;
+ --app-shell-type-body-sm-relaxed-size: 0.75rem;
+ --app-shell-type-body-sm-relaxed-line-height: calc(20 / 12);
+ --app-shell-type-body-sm-relaxed-weight: 400;
+ --app-shell-type-body-sm-relaxed-letter-spacing: 0em;
+}
diff --git a/packages/core/src/lib/utils.test.ts b/packages/core/src/lib/utils.test.ts
new file mode 100644
index 000000000..6b5c8db76
--- /dev/null
+++ b/packages/core/src/lib/utils.test.ts
@@ -0,0 +1,47 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { cn } from "./utils";
+
+const defaultTheme = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "../assets/themes/default.css"),
+ "utf8",
+);
+const roles = [
+ ...new Set([...defaultTheme.matchAll(/^\s*--app-shell-type-([a-z-]+)-size:/gm)].map((m) => m[1])),
+];
+
+describe("cn", () => {
+ it("keeps a typography role next to a text color", () => {
+ expect(roles.length).toBeGreaterThan(0);
+ for (const role of roles) {
+ expect(cn(`text-${role}`, "text-muted-foreground"), role).toBe(
+ `text-${role} text-muted-foreground`,
+ );
+ }
+ });
+
+ it("keeps a typography role next to font-mono", () => {
+ expect(cn("text-code-sm font-mono", "text-muted-foreground")).toBe(
+ "text-code-sm font-mono text-muted-foreground",
+ );
+ });
+
+ it("keeps leading-none only when it comes after the role", () => {
+ // Same behavior as a stock size such as text-lg: a size class drops an earlier leading class.
+ expect(cn("text-heading-md", "leading-none")).toBe("text-heading-md leading-none");
+ expect(cn("leading-none", "text-heading-md")).toBe("text-heading-md");
+ });
+
+ it("lets a later role or stock font size replace an earlier one", () => {
+ expect(cn("text-body-md", "text-heading-lg")).toBe("text-heading-lg");
+ expect(cn("text-label-md", "text-sm")).toBe("text-sm");
+ expect(cn("text-sm", "text-label-md")).toBe("text-label-md");
+ });
+
+ it("still merges stock utilities", () => {
+ expect(cn("p-2", "p-4")).toBe("p-4");
+ expect(cn("text-red-500", "text-muted-foreground")).toBe("text-muted-foreground");
+ });
+});
diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts
index a5ef19350..11b742c73 100644
--- a/packages/core/src/lib/utils.ts
+++ b/packages/core/src/lib/utils.ts
@@ -1,5 +1,28 @@
import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { extendTailwindMerge } from "tailwind-merge";
+
+/**
+ * Typography role names from `theme.bridge.css` (`text-heading-lg`, ...).
+ * tailwind-merge treats an unknown `text-*` value as a text color, so without
+ * this list `cn("text-label-md", "text-muted-foreground")` keeps only the color.
+ * `src/lib/utils.test.ts` checks that this list matches the roles in `default.css`.
+ */
+const TYPOGRAPHY_ROLES = [
+ "heading-lg",
+ "heading-md",
+ "heading-sm",
+ "body-md",
+ "body-sm",
+ "label-md",
+ "label-sm",
+ "code-sm",
+ "body-md-relaxed",
+ "body-sm-relaxed",
+];
+
+const twMerge = extendTailwindMerge({
+ extend: { classGroups: { "font-size": [{ text: TYPOGRAPHY_ROLES }] } },
+});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
diff --git a/packages/core/tests/typography-compile.test.ts b/packages/core/tests/typography-compile.test.ts
new file mode 100644
index 000000000..75fc503f3
--- /dev/null
+++ b/packages/core/tests/typography-compile.test.ts
@@ -0,0 +1,109 @@
+// @vitest-environment node
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import tailwind from "@tailwindcss/postcss";
+import postcss from "postcss";
+import { describe, expect, it } from "vitest";
+
+/**
+ * Compiles theme.bridge.css with Tailwind and checks the emitted utility rules.
+ * The string-level checks in src/assets/theme-bridge.test.ts confirm that the
+ * bridge names the right variables. This test confirms that Tailwind turns those
+ * entries into the four declarations a role needs, both for AppShell's own
+ * prefixed build (`astw:`) and for a consumer's unprefixed build.
+ */
+const assetsDir = join(dirname(fileURLToPath(import.meta.url)), "../src/assets");
+const defaultTheme = readFileSync(join(assetsDir, "themes/default.css"), "utf8");
+
+async function compile(header: string, candidates: string[]): Promise {
+ const css = `${header}
+@source inline("${candidates.join(" ")}");
+@import "./theme.bridge.css";
+`;
+ // `from` must be a real path inside packages/core so that `tailwindcss` and
+ // `./theme.bridge.css` resolve.
+ const result = await postcss([tailwind()]).process(css, { from: join(assetsDir, "entry.css") });
+ return result.css;
+}
+
+/** The declarations of the rule with the given selector, as a property to value map. */
+function declarationsOf(css: string, selector: string): Map {
+ const root = postcss.parse(css);
+ const declarations = new Map();
+ root.walkRules((rule) => {
+ if (rule.selector !== selector) return;
+ rule.walkDecls((decl) => {
+ declarations.set(decl.prop, decl.value.replace(/\s+/g, " ").trim());
+ });
+ });
+ return declarations;
+}
+
+describe("typography role utilities", () => {
+ it.each([
+ [
+ "prefixed (AppShell build)",
+ '@import "tailwindcss" prefix(astw) source(none);',
+ "astw:",
+ "astw\\:",
+ ],
+ ["unprefixed (consumer build)", '@import "tailwindcss" source(none);', "", ""],
+ ])(
+ "%s emits all four declarations from the role variables",
+ async (_name, header, prefix, escaped) => {
+ const css = await compile(header, [`${prefix}text-label-md`, `${prefix}text-heading-lg`]);
+
+ const label = declarationsOf(css, `.${escaped}text-label-md`);
+ expect(label.get("font-size")).toBe("var(--app-shell-type-label-md-size)");
+ expect(label.get("line-height")).toBe(
+ "var(--tw-leading, var(--app-shell-type-label-md-line-height))",
+ );
+ expect(label.get("font-weight")).toBe(
+ "var(--tw-font-weight, var(--app-shell-type-label-md-weight))",
+ );
+ expect(label.get("letter-spacing")).toBe(
+ "var(--tw-tracking, var(--app-shell-type-label-md-letter-spacing))",
+ );
+
+ const heading = declarationsOf(css, `.${escaped}text-heading-lg`);
+ expect(heading.get("font-size")).toBe("var(--app-shell-type-heading-lg-size)");
+ },
+ );
+
+ it("lets stock leading-* utilities override the role line-height", async () => {
+ // `leading-none` sets --tw-leading, which the role reads before its own variable.
+ const css = await compile('@import "tailwindcss" source(none);', [
+ "text-label-md",
+ "leading-none",
+ ]);
+ expect(declarationsOf(css, ".leading-none").get("--tw-leading")).toBe("1");
+ expect(declarationsOf(css, ".text-label-md").get("line-height")).toMatch(/^var\(--tw-leading,/);
+ });
+
+ it("emits every role defined in the default palette", async () => {
+ const roles = [
+ ...new Set(
+ [...defaultTheme.matchAll(/^\s*--app-shell-type-([a-z-]+)-size:/gm)].map((m) => m[1]),
+ ),
+ ];
+ const css = await compile(
+ '@import "tailwindcss" source(none);',
+ roles.map((role) => `text-${role}`),
+ );
+ for (const role of roles) {
+ const decls = declarationsOf(css, `.text-${role}`);
+ expect(decls.get("font-size"), role).toBe(`var(--app-shell-type-${role}-size)`);
+ expect(decls.get("line-height"), role).toBe(
+ `var(--tw-leading, var(--app-shell-type-${role}-line-height))`,
+ );
+ expect(decls.get("letter-spacing"), role).toBe(
+ `var(--tw-tracking, var(--app-shell-type-${role}-letter-spacing))`,
+ );
+ expect(decls.get("font-weight"), role).toBe(
+ `var(--tw-font-weight, var(--app-shell-type-${role}-weight))`,
+ );
+ expect(decls.size, role).toBe(4);
+ }
+ });
+});