diff --git a/.agents/references/component-design.md b/.agents/references/component-design.md
index d878fd90..48a09853 100644
--- a/.agents/references/component-design.md
+++ b/.agents/references/component-design.md
@@ -431,7 +431,7 @@ Review whether component styling remains self-contained and consistent:
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`.
-- Render content text with `Text`: titles, descriptions, captions, hints, messages, error text, and key and value display. Pass a Base UI part, or an element that differs from the role's default tag, with `render`, and keep the part's `data-slot` on the render element.
+- Render content text with `Text`: titles, descriptions, captions, hints, messages, error text, and key and value display. For a plain `h1` to `h6`, `p`, `span`, or `div`, use the element component (`Text.h3`, `Text.span`, ...), and put the element's `data-slot` on it. Write `variant` only when the role differs from the element's default (`heading-lg` for `h1`, `heading-md` for `h2`, `heading-sm` for `h3` to `h6`, `body-md` for `p` and `div`, `label-md` for `span`). Use `render` only for a Base UI part or another component, or for other elements such as `dt` or `code`, and keep the part's `data-slot` on the render element.
- Use the role utility (`astw:text-heading-md`, `astw:text-label-md`, ...) for text that is part of a control or sits inside one (buttons, inputs, menu and list items, badges, toolbars, table cells), in cva strings, and on containers that set the size for their children.
- Do not write new raw combinations such as `astw:text-lg astw:font-semibold` or `astw:text-sm astw:font-medium`. `tests/typography-usage.test.ts` fails on a raw size.
- Combine a role only with the four modifiers: `astw:leading-none` (single-line text), `astw:tabular-nums`, `astw:font-semibold` (emphasis on inline text), and `astw:uppercase`. Do not add other `leading-*`, `tracking-*`, or `text-[Npx]` values. 12px is the minimum size.
@@ -441,15 +441,11 @@ Text that a component renders takes its size, line height, weight, and letter sp
```tsx
// Good
-}
->
+
{title}
-
+{updatedAt}
+} />
```
diff --git a/.changeset/skill-text-element-components.md b/.changeset/skill-text-element-components.md
new file mode 100644
index 00000000..7aa80429
--- /dev/null
+++ b/.changeset/skill-text-element-components.md
@@ -0,0 +1,5 @@
+---
+"@tailor-platform/app-shell": patch
+---
+
+AppShell components and the document detail page example in the `app-shell-patterns` skill use the `Text` element components, such as `Text.h3` and `Text.span`, where they used `render` only to choose a plain HTML element. The rendered elements and classes do not change. `render` stays for Base UI parts, other components, and other elements.
diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md
index 5c09c414..48193676 100644
--- a/decisions/typography-roles.md
+++ b/decisions/typography-roles.md
@@ -104,8 +104,8 @@ Limit: this does not let a role replace a size that core sets on its own element
> Status: **Decided.**
- **Text or the role utility.** Content text renders with `Text`: titles, descriptions, captions, hints, messages, error text, and key and value display. Text that is part of a control, or sits inside one, keeps the role utility: buttons, inputs, menu, listbox, palette and tab items and their group labels, badges, counters, toolbars, pagination, and table cells. cva strings, shared class strings, and containers that set the size for their children also keep the utility. Radix Themes, Primer, and the Atlassian Design System split their components the same way. Polaris uses its `Text` almost everywhere; Mantine and Chakra do not use theirs inside components.
-- **`render` and `data-slot`.** A Base UI part, or an element whose tag differs from the role's default, is passed with `render`, for example `} />`. The `data-slot` stays on the render element, because its props win over the `data-slot="text"` that `Text` sets.
-- **Lint.** Where `render` gets an empty `h1`, `h3`, `h4`, `a`, or `label`, the matching `jsx-a11y` rule is disabled on that line, as the `Text` documentation shows for apps.
+- **`render` and `data-slot`.** A Base UI part, or an element whose tag differs from the role's default, is passed with `render` (for a plain `h1` to `h6`, `p`, `span`, or `div`, see the fourth change), for example `} />`. The `data-slot` stays on the render element, because its props win over the `data-slot="text"` that `Text` sets.
+- **Lint.** Where `render` gets an empty `h1`, `h3`, `h4`, `a`, or `label`, the matching `jsx-a11y` rule is disabled on that line, as the `Text` documentation shows for apps. _Updated by the fourth change: headings now use `Text.h1` to `Text.h6`, so only the `a` and `label` disable comments remain._
- **`Text` passes children only when given.** A render part that supplies its own content keeps it. Base UI's `Field.Error` shows the validation message this way.
- **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.
@@ -123,4 +123,4 @@ Limit: this does not let a role replace a size that core sets on its own element
- **No exported props type.** `React.ComponentProps` 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.
+- **AppShell's own components.** A follow-up change replaced the 43 places where a component passed a plain `h1` to `h6`, `p`, `span`, or `div` to `render` with the element components, and removed the 13 `heading-has-content` disable comments. Each place keeps its role: it writes `variant` only where the role differs from the element's default (36 places), so the rendered element and classes do not change. A `data-slot` or other attribute on the render element moves to the element component, where it still wins over `data-slot="text"`. `render` stays for Base UI parts and other components (`Dialog.Title`, `Field.Error`, `Link`) and for other elements (`dt`, `dd`, `code`, `pre`, `caption`, `address`, `a`). The document detail page example changed in the same way.
diff --git a/docs-manifest.json b/docs-manifest.json
index de1e4925..8767a1dd 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -650,9 +650,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "a87752a8b4c3e0b7",
+ "outline": "026a063f3026c87a",
"snapshot": null,
- "outputMd": "5a6612349cb50ef1",
+ "outputMd": "930db027a28bbe24",
"examples": null
}
},
@@ -1854,7 +1854,7 @@
"packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "aa60091d0e430762",
"packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48",
"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/pages/document-detail.md": "930db027a28bbe24",
"packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769",
"packages/core/skills/app-shell-patterns/SKILL.md": "1aa14970eee9bce0"
}
diff --git a/docs-src/pages/document-detail.docs.outline.md b/docs-src/pages/document-detail.docs.outline.md
index fc0e36fb..5a45797f 100644
--- a/docs-src/pages/document-detail.docs.outline.md
+++ b/docs-src/pages/document-detail.docs.outline.md
@@ -645,9 +645,9 @@ const formatDay = (value: string) =>
* or the two dates do not match every other value on the card.
*/
const Value = ({ children }: { children: React.ReactNode }) => (
- }>
+
{children}
-
+
);
/** House link: primary colour at rest, underline only on hover. */
@@ -711,9 +711,9 @@ export default function PurchaseOrderDetailPage({
{order.holds.map((hold) => (
))}
diff --git a/docs/pages/document-detail.md b/docs/pages/document-detail.md
index 8ca76cbf..8ddfe115 100644
--- a/docs/pages/document-detail.md
+++ b/docs/pages/document-detail.md
@@ -619,9 +619,9 @@ const formatDay = (value: string) =>
* or the two dates do not match every other value on the card.
*/
const Value = ({ children }: { children: React.ReactNode }) => (
- }>
+
{children}
-
+
);
/** House link: primary colour at rest, underline only on hover. */
@@ -685,9 +685,9 @@ export default function PurchaseOrderDetailPage({
{order.holds.map((hold) => (