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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/text-element-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@tailor-platform/app-shell": minor
---

Add element components to `Text`: `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div`. Each one always renders its named HTML element with a default typography role, and takes `variant`, `className`, the element's own attributes, and a ref.

```tsx
<Text.h1>Purchase orders</Text.h1>
<Text.h3 variant="heading-lg">Line items</Text.h3>
<Text.p className="text-muted-foreground">Orders sent this month.</Text.p>
<Text.span variant="body-sm">Updated recently</Text.span>
```

The defaults are `heading-lg` for `Text.h1`, `heading-md` for `Text.h2`, `heading-sm` for `Text.h3` to `Text.h6`, `body-md` for `Text.p` and `Text.div`, and `label-md` for `Text.span`. Headings written this way do not need a `jsx-a11y/heading-has-content` disable comment. `Text` and its `render` prop do not change; use `render` for other elements and to compose with another component.
18 changes: 16 additions & 2 deletions decisions/typography-roles.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Decision: typography role tokens

> Status: **Decided — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities, a `Text` component on top of them, and use them in AppShell's own components (see "Third change" at the end).**
> Status: **Decided — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities, a `Text` component on top of them, and use them in AppShell's own components (see "Third change"). "Fourth change" adds element components such as `Text.h1`.**
> Scope: role names and values, how they reach Tailwind, the rules for combining them, and the `Text` component (see "Second change" at the end). "Third change" covers applying the roles to AppShell components and moving sizes below 12px to 12px.

## Context
Expand Down Expand Up @@ -93,7 +93,7 @@ Limit: this does not let a role replace a size that core sets on its own element

- **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.
- **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. _Updated by the fourth change: headings now use `Text.h1` to `Text.h6`, and the disable comment remains only for other elements in `render`._
- **No color or modifier props.** Color and the four modifiers stay in `className`, as for the utilities. A consumer `leading-none`, `font-semibold`, 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/browser/theme.test.tsx` checks the computed styles in Chromium; `tests/typography-compile.test.ts` retains the full role-declaration checks.
- **No `textVariants` export.** tailor-inc/platform-planning#1580 asked for a variants export. It is descoped, not deferred. The `text-<role>` utilities already apply the same role mappings, so a cva export would be a third way to do the same thing, and it would put `astw:`-prefixed classes into consumer code. Unlike `Button` and `Badge`, `Text` does not export its cva function.
- **`code-sm` includes `font-mono`.** The utility cannot set the font family, but the component can.
Expand All @@ -110,3 +110,17 @@ Limit: this does not let a role replace a size that core sets on its own element
- **Value changes.** Text below 12px moves to 12px. Line heights outside the allowed modifiers move to the role's line height, or to a relaxed role for chat messages. The uppercase section labels, the ActivityCard day label, and the menu group label use `label-sm` at weight 500. `code-sm` adds 2px of line height. In key and value pairs, the key is `label-md` with a muted color and the value is `body-md`, the same as numbers in DataTable. An `h1` to `h6` element takes a heading role (chat empty state title, default sidebar app title). `Alert.Title` drops `tracking-tight`. A selected item (sidebar, chat history, DataTable filter panel) uses `font-semibold` with its background, as Primer and Polaris do.
- **Element types do not change.** Under the cream and bloom palettes, headings render as before. The open question about their `h1` to `h6` rule stays open.
- **Guard.** `tests/typography-usage.test.ts` fails on a raw Tailwind text size in a component, on a role class with an unknown name, and on `text-code-sm` without `font-mono`.

## Fourth change: element components

> Status: **Decided.** Tracking issue: tailor-inc/platform-planning#2225 (under the umbrella tailor-inc/platform-planning#2214). Discussed in `#pf-app-shell` on 2026-10-27.

- **API.** `Text.h1` to `Text.h6`, `Text.p`, `Text.span`, and `Text.div` always render their named element. They take `variant`, `className`, the element's own attributes, and a ref typed for the element. They do not take `render`. `Text` and its `render` prop do not change, and `render` stays the way to compose with another component. The names are lowercase to show that each one is an HTML element with a default role, not a higher-level component.
- **Why this is not a second way to choose the element.** The second change rejected a `level` prop because it would sit next to `render`. The element components split the work instead: a plain HTML element uses `Text.<element>`, and composition uses `render`. An `as` prop was also considered and dropped, because it does the same thing as `Text.h1`.
- **Defaults.** `Text.h1` is `heading-lg`, `Text.h2` is `heading-md`, and `Text.h3` to `Text.h6` are `heading-sm`. `Text.p` is `body-md`. `Text.span` is `label-md`, not `body-md`, to follow the default elements of `Text`: body roles render `p`, and label and code roles render `span`. `Text.div` is `body-md`, the default role of `Text`, because `div` is a block element like `p`. A default is only a starting point; any role works on any element.
- **Lint.** `jsx-a11y/heading-has-content` does not report `<Text.h1>`, because the rule sees the element name `Text.h1`, not `h1` (checked with oxlint 1.64). Headings no longer need the disable comment. Note that the rule also does not report an empty `<Text.h1 />`.
- **Scope.** Only the elements above. Other elements, such as `code`, `small`, `pre`, `blockquote`, `figcaption`, `time`, `kbd`, and `label`, use `Text` with `render`, and the disable comment where a lint rule reports the empty element. More elements can be added if they are used often.
- **No exported props type.** `React.ComponentProps<typeof Text.h2>` gives the props type. A named export would be another public name that cannot be removed later without a breaking change, while adding one later is not breaking.
- **Implementation.** The element components render the element directly, without Base UI's `useRender`, because they take no `render` prop. They set `data-slot="text"` and the same classes as `Text`.
- **Documentation.** The heading examples in the quickstart, the styling guide, and the `app-shell-patterns` skill use the element components.
- **Follow-up change** (not in this PR): AppShell's own components keep `render` for now; changing their headings to element components is a separate change.
24 changes: 12 additions & 12 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,9 +1160,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "9b52f8cdbf18c8ac",
"outline": "82b05468b438d199",
"snapshot": null,
"outputMd": "f2ad75a76a1d5206",
"outputMd": "2261883b6d0069d3",
"examples": null
}
},
Expand Down Expand Up @@ -1381,9 +1381,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "9f6a42c42dd3f54f",
"outline": "4081996c98e029b0",
"snapshot": null,
"outputMd": "7ee0130a20b88310",
"outputMd": "1ed3e6d92e582e26",
"examples": "4d19f4f688e0f0ac"
}
},
Expand Down Expand Up @@ -1431,11 +1431,11 @@
"claims": [],
"symbols": ["Text", "TextProps"],
"hashes": {
"typeSurface": "ed54a16ca677dbe3",
"outline": "114c81c557b82a18",
"snapshot": "cb5f569f7dd2ecfc",
"outputMd": "5b6cf767868d6558",
"examples": "4a832708dc79474b"
"typeSurface": "053c8236047c6ec4",
"outline": "1441d9d6e3f5975b",
"snapshot": "83be7198ffac5470",
"outputMd": "24262ae6cb49a138",
"examples": "c31a669057c28ba7"
}
},
"textarea": {
Expand Down Expand Up @@ -1770,7 +1770,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": "5a3c8bac248061f9",
"packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "7ee0130a20b88310",
"packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "1ed3e6d92e582e26",
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af",
"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 @@ -1812,7 +1812,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": "5b6cf767868d6558",
"packages/core/skills/app-shell-patterns/references/components/text.md": "24262ae6cb49a138",
"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 @@ -1856,6 +1856,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "59c93617dc135033",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "5a6612349cb50ef1",
"packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769",
"packages/core/skills/app-shell-patterns/SKILL.md": "b5aacf04ae361852"
"packages/core/skills/app-shell-patterns/SKILL.md": "1aa14970eee9bce0"
}
}
26 changes: 15 additions & 11 deletions docs-src/components/text.docs.examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ 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>
<Text.h2>Purchase orders</Text.h2>
<Text.p className="text-muted-foreground">Orders sent to suppliers this month.</Text.p>
</div>
);
}
Expand All @@ -13,15 +13,9 @@ 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.div variant="heading-lg">heading-lg: Purchase orders</Text.div>
<Text.div variant="heading-md">heading-md: Line items</Text.div>
<Text.div variant="heading-sm">heading-sm: Shipping address</Text.div>
<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>
Expand All @@ -31,6 +25,16 @@ export function Roles() {
);
}

export function ElementComponents() {
return (
<div className="flex flex-col gap-1">
<Text.h3>Shipping address</Text.h3>
<Text.p className="text-muted-foreground">Orders ship from the main warehouse.</Text.p>
<Text.span className="tabular-nums">3 packages</Text.span>
</div>
);
}

export function ColorAndModifiers() {
return (
<div className="flex flex-col gap-3">
Expand Down
Loading
Loading