@tailor-platform/app-shell 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/app-shell-core.css +1 -1
  2. package/dist/app-shell.d.ts +762 -51
  3. package/dist/app-shell.js +7642 -5636
  4. package/dist/{appshell-B0wh5Lq0.js → appshell-Y1AYCuVJ.js} +323 -304
  5. package/dist/semantic-contrast.test.ts +227 -0
  6. package/dist/style-entry.d.ts +61 -26
  7. package/dist/testing.d.ts +66 -29
  8. package/dist/testing.js +1 -1
  9. package/dist/theme-bridge.test.ts +124 -1
  10. package/dist/theme.bridge.css +178 -0
  11. package/dist/theme.css +8 -2
  12. package/dist/themes/_template.css +8 -3
  13. package/dist/themes/bloom.css +0 -8
  14. package/dist/themes/cream.css +0 -8
  15. package/dist/themes/default.css +458 -103
  16. package/dist/vite-plugin.d.ts +61 -26
  17. package/package.json +2 -2
  18. package/skills/app-shell-patterns/SKILL.md +48 -37
  19. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +123 -91
  20. package/skills/app-shell-patterns/references/api/define-module.md +4 -6
  21. package/skills/app-shell-patterns/references/api/define-resource.md +2 -2
  22. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +53 -47
  23. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +30 -26
  24. package/skills/app-shell-patterns/references/api/use-app-shell.md +37 -30
  25. package/skills/app-shell-patterns/references/api/use-toast.md +28 -31
  26. package/skills/app-shell-patterns/references/components/app-shell.md +4 -4
  27. package/skills/app-shell-patterns/references/components/card.md +79 -7
  28. package/skills/app-shell-patterns/references/components/data-table.md +599 -77
  29. package/skills/app-shell-patterns/references/components/date-picker.md +32 -21
  30. package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
  31. package/skills/app-shell-patterns/references/components/sidebar-group.md +12 -6
  32. package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
  33. package/skills/app-shell-patterns/references/components/text.md +198 -0
  34. package/skills/app-shell-patterns/references/concepts/custom-components.md +1 -1
  35. package/skills/app-shell-patterns/references/concepts/file-based-routing.md +7 -1
  36. package/skills/app-shell-patterns/references/concepts/graphql.md +20 -14
  37. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +41 -3
  38. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +32 -0
  39. package/skills/app-shell-patterns/references/concepts/styling-theming.md +278 -65
  40. package/skills/app-shell-patterns/references/migrations.md +43 -1
  41. package/skills/app-shell-patterns/references/pages/document-detail.md +6 -6
  42. package/skills/app-shell-patterns/references/patterns/form-modal.md +26 -20
  43. package/skills/app-shell-patterns/references/patterns/i18n-labels.md +139 -0
  44. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +129 -118
  45. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +5 -4
@@ -153,22 +153,28 @@ function CreateOrderButton() {
153
153
 
154
154
  ### `additionalTypenames`
155
155
 
156
- When a mutation creates/updates/deletes an entity that another query already has cached, urql won't know to refetch unless you pass `additionalTypenames`. Use it on **both** the query and the mutation when they share a typename:
156
+ When a mutation creates/updates/deletes an entity that another query already has cached, urql won't know to refetch unless you pass `additionalTypenames`. Use it on **both** the query and the mutation when they share a typename.
157
+
158
+ Define the query's static `context` at module scope, outside the component, so its reference stays stable between renders. `useQuery` lists `context` as a hook dependency, so a fresh object on each render re-creates the query subscription and can cause query re-execution loops. The mutation's second argument is passed per call and is not a hook dependency, so it can stay inline.
157
159
 
158
160
  ```tsx
159
- // Query side — declares it cares about the Order typename
160
- const [{ data }] = useQuery({
161
- query: OrdersQuery,
162
- variables: { first: 25 },
163
- context: { additionalTypenames: ["Order"] },
164
- });
165
-
166
- // Mutation side — declares it produces Order writes
167
- const [, createOrder] = useMutation(CreateOrderMutation);
168
- await createOrder(
169
- { input },
170
- { additionalTypenames: ["Order"] }, // triggers refetch of any query subscribed to 'Order'
171
- );
161
+ const ordersQueryContext = { additionalTypenames: ["Order"] };
162
+
163
+ function useOrders() {
164
+ // Query side — declares it cares about the Order typename
165
+ const [{ data }] = useQuery({
166
+ query: OrdersQuery,
167
+ variables: { first: 25 },
168
+ context: ordersQueryContext,
169
+ });
170
+
171
+ // Mutation side — declares it produces Order writes
172
+ const [, executeCreateOrder] = useMutation(CreateOrderMutation);
173
+ const createOrder = (input: CreateOrderInput) =>
174
+ executeCreateOrder({ input }, { additionalTypenames: ["Order"] });
175
+
176
+ return { data, createOrder };
177
+ }
172
178
  ```
173
179
 
174
180
  Skip `additionalTypenames` only when the mutation's return type already includes the affected entity _and_ every cached query for it touched the same `id` — urql will then refresh by id. When in doubt, add it.
@@ -159,15 +159,15 @@ const MyComponent = () => {
159
159
 
160
160
  ## Declarative Redirects
161
161
 
162
- `Navigate` redirects as a render result, which avoids the `useEffect` + `navigate()` pattern (that pattern renders the old page for a frame before it redirects):
162
+ `Navigate` redirects as a render result, which avoids the `useEffect` + `navigate()` pattern (that pattern renders the old page for a frame before it redirects). `contextData` is typed through the `AppShellRegister` augmentation described in [useAppShell](../api/use-app-shell.md):
163
163
 
164
164
  ```tsx
165
165
  import { Navigate, useAppShellData } from "@tailor-platform/app-shell";
166
166
 
167
167
  const AdminPage = () => {
168
- const { currentUser } = useAppShellData();
168
+ const { contextData } = useAppShellData();
169
169
 
170
- if (currentUser?.role !== "admin") {
170
+ if (contextData.currentUser?.role !== "admin") {
171
171
  return <Navigate to="/dashboard" replace />;
172
172
  }
173
173
 
@@ -207,6 +207,44 @@ defineResource({
207
207
  // Breadcrumb shows: "Orders > Order #12345"
208
208
  ```
209
209
 
210
+ ### Localized Breadcrumb Titles
211
+
212
+ Pass `labels.t(key)` directly to `meta.breadcrumbTitle`, just like `meta.title`.
213
+ AppShell recognizes these translation functions and resolves them using the current locale.
214
+ For labels that depend on the URL segment, return a `LocalizedString` from a segment callback:
215
+
216
+ ```tsx
217
+ import { defineI18nLabels, defineResource } from "@tailor-platform/app-shell";
218
+
219
+ const labels = defineI18nLabels({
220
+ en: {
221
+ orders: "All Orders",
222
+ order: ({ id }: { id: string }) => `Order #${id}`,
223
+ },
224
+ ja: {
225
+ orders: "すべての注文",
226
+ order: ({ id }: { id: string }) => `注文 #${id}`,
227
+ },
228
+ });
229
+
230
+ const ordersResource = defineResource({
231
+ path: "orders",
232
+ meta: { breadcrumbTitle: labels.t("orders") },
233
+ subResources: [
234
+ defineResource({
235
+ path: ":id",
236
+ meta: { breadcrumbTitle: (segment) => labels.t("order", { id: segment }) },
237
+ }),
238
+ ],
239
+ });
240
+ ```
241
+
242
+ The same pattern works for `defineModule` and file-based `appShellPageProps.meta`.
243
+ Other callbacks still receive the URL segment. The existing `() => labels.t(key)` form remains supported.
244
+ Handwritten locale functions must be returned from an outer callback, for example `breadcrumbTitle: () => (locale) => locale === "ja" ? "注文" : "Orders"`.
245
+ If navigation and breadcrumbs share a label, omit `breadcrumbTitle` and use `title: labels.t(key)` instead.
246
+ Existing strings and `(segment) => string` callbacks remain supported; no `as string` cast is needed.
247
+
210
248
  ### Dynamic Breadcrumb Titles
211
249
 
212
250
  Use the `useOverrideBreadcrumb` hook to replace a breadcrumb segment with a data-driven value from within the rendered page component:
@@ -182,6 +182,38 @@ const labels = defineI18nLabels({
182
182
 
183
183
  ---
184
184
 
185
+ ### SidebarSection
186
+
187
+ A static heading for a run of flat navigation items. Unlike `SidebarGroup`, a section is neither collapsible nor navigable, and its items keep top-level styling. In the icon rail the heading is hidden.
188
+
189
+ #### Props
190
+
191
+ | Prop | Type | Required | Description |
192
+ | ---------- | ----------------- | -------- | --------------------------------- |
193
+ | `title` | `LocalizedString` | Yes | Section heading (supports i18n) |
194
+ | `children` | `ReactNode` | Yes | Child items (usually SidebarItem) |
195
+
196
+ #### Example
197
+
198
+ ```tsx
199
+ <DefaultSidebar>
200
+ <SidebarItem to="/overview" />
201
+ <SidebarSeparator />
202
+ <SidebarSection title="Services">
203
+ <SidebarItem to="/ai-gateways" />
204
+ <SidebarItem to="/applications" />
205
+ </SidebarSection>
206
+ <SidebarSeparator />
207
+ <SidebarSection title="Logs">
208
+ <SidebarItem to="/activity-logs" />
209
+ </SidebarSection>
210
+ </DefaultSidebar>
211
+ ```
212
+
213
+ Use `SidebarGroup` when users should be able to collapse the items or when they belong under a parent page; use `SidebarSection` when you only need a label above items that stay visible.
214
+
215
+ ---
216
+
185
217
  ### SidebarSeparator
186
218
 
187
219
  A visual divider between sidebar sections.
@@ -11,7 +11,7 @@ Styling is done with **Tailwind CSS v4** against `@tailor-platform/app-shell`'s
11
11
 
12
12
  **The tokens are the rails.** Consistency across customers, apps, and AI runs comes from the token system, not from rules written in prose. A hand-typed `#fff` or `padding: 13px` is not a "small deviation" — it is the mechanism by which consistency dies.
13
13
 
14
- **Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
14
+ **Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. The colour scales (`--primitive-*`) are internal: they are not bridged to Tailwind and are not listed. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
15
15
 
16
16
  ## Setup
17
17
 
@@ -86,14 +86,14 @@ Only import one palette at a time.
86
86
 
87
87
  Theme tokens live in `packages/core/src/assets/themes/`. Copy `_template.css` to start a new palette — it lists exactly which sections to fill in for light and dark mode.
88
88
 
89
- | Section | Required? | What to set |
90
- | --------------------- | --------------------- | ------------------------------------------------------------------------------ |
91
- | **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
92
- | **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
93
- | **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
94
- | **4. Palette** | Optional | Radius, chart colors, shadows |
95
- | **5. Semantic** | Do not duplicate | Status and alert tokens inherit from `default.css` |
96
- | **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
89
+ | Section | Required? | What to set |
90
+ | --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
91
+ | **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
92
+ | **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
93
+ | **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
94
+ | **4. Palette** | Optional | Radius, chart colors, shadows |
95
+ | **5. Semantic** | Inherit | Colour scales, status and alert tokens inherit from `default.css`. Override a colour role (`--{intent}-{role}`) only to recolour the status components |
96
+ | **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
97
97
 
98
98
  A palette is selected by CSS import, not by an AppShell prop. Import exactly one theme file after `@tailor-platform/app-shell/styles`; if you import none, the default palette from `styles` is used.
99
99
 
@@ -159,33 +159,69 @@ Colors follow shadcn-style semantic naming: a surface token and its matching `-f
159
159
  | `--border` | hairlines, dividers | `border-border` |
160
160
  | `--input` | form control borders | `border-input` |
161
161
  | `--ring` | focus rings | `ring-ring` / `outline-ring` |
162
+ | `--backdrop` | dim layer behind a modal | `bg-backdrop` |
162
163
 
163
164
  Surfaces are named by **role**, not by depth — there is no numbered `surface-1/2/3` ladder, and no third tier of body text below `muted-foreground`. Stack depth with `background` → `card` → `muted` plus a border or shadow, and reach for `popover` when the surface actually floats.
164
165
 
166
+ `--backdrop` is the dim layer behind `Dialog` and `Sheet` (black at 50% in both modes). Use `bg-backdrop` for your own modal layers instead of `bg-black/50`. Palettes inherit it; override it on `:root` to change every backdrop at once.
167
+
168
+ In the default palette, the surface, text and line tokens in this table read the internal gray scale, a white step, and white alpha steps (dark-mode lines), so their values match the gray used across the palette. `--backdrop` reads a black alpha step, and `--ring` is mixed from `--primary`. The other palettes set their own values.
169
+
165
170
  #### Brand & action
166
171
 
167
- | Token | Use | Tailwind |
168
- | -------------------------------------------- | -------------------------------- | -------------------------------------------- |
169
- | `--primary` / `--primary-foreground` | primary buttons, emphasis | `bg-primary` / `text-primary-foreground` |
170
- | `--secondary` / `--secondary-foreground` | secondary buttons, neutral chips | `bg-secondary` / `text-secondary-foreground` |
171
- | `--accent` / `--accent-foreground` | hover and selected nav states | `bg-accent` / `text-accent-foreground` |
172
- | `--destructive` / `--destructive-foreground` | destructive actions, errors | `bg-destructive` / `text-destructive` |
172
+ | Token | Use | Tailwind |
173
+ | -------------------------------------------- | ------------------------------------------- | -------------------------------------------- |
174
+ | `--primary` / `--primary-foreground` | primary buttons, emphasis | `bg-primary` / `text-primary-foreground` |
175
+ | `--secondary` / `--secondary-foreground` | secondary buttons, neutral chips | `bg-secondary` / `text-secondary-foreground` |
176
+ | `--accent` / `--accent-foreground` | hover and selected nav states | `bg-accent` / `text-accent-foreground` |
177
+ | `--destructive` / `--destructive-foreground` | deprecated alias of danger solid / contrast | `bg-destructive` / `text-destructive` |
178
+
179
+ **Deprecated.** `--destructive`, `--destructive-foreground` and their utilities remain for existing code and will be removed in the next major. New code uses the danger roles: `bg-danger-solid`, `text-danger-contrast`, and `text-danger-text` for text. `Button variant="destructive"` is not deprecated yet; the next major renames it to `danger`.
180
+
181
+ `--destructive` and `--destructive-foreground` are aliases of the danger `solid` and `contrast` roles ([semantic color roles](#semantic-color-roles)), so `bg-destructive` is the danger solid fill in both modes and `Button variant="destructive"` reads the danger roles directly. `text-destructive` still works and resolves to the solid. For text, use `text-danger-text`, which meets 4.5:1 on `--card` and `--background`; the solid is `#dc2626` in dark mode, 3.71:1 on the dark card. To recolour destructive actions, override `--danger-solid` (and the other danger roles), not `--destructive`.
173
182
 
174
183
  There are no `-hover` or `-active` brand tokens. Express interaction states with Tailwind variants and opacity — `hover:bg-primary/90`, `active:bg-primary/80` — which is what AppShell's own components do.
175
184
 
176
185
  #### Status
177
186
 
178
- Five status colors, used for badge fills and status dots:
187
+ **Deprecated.** These five tokens and their utilities remain for existing code and will be removed in the next major. New code uses the [semantic color roles](#semantic-color-roles) below. In the default palette they are aliases of those roles, so status colors and Alert colors agree:
188
+
189
+ | Token | Use | Tailwind | Use instead |
190
+ | -------------------- | ---------------------------------------------------- | --------------------- | ---------------------- |
191
+ | `--status-default` | none / not applicable (follows `--muted-foreground`) | `bg-status-default` | `bg-neutral-indicator` |
192
+ | `--status-neutral` | informational | `bg-status-neutral` | `bg-info-solid` |
193
+ | `--status-completed` | success, completed | `bg-status-completed` | `bg-success-solid` |
194
+ | `--status-attention` | warning, needs attention | `bg-status-attention` | `bg-warning-solid` |
195
+ | `--status-danger` | error, blocked | `bg-status-danger` | `bg-danger-solid` |
196
+
197
+ Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying a fill directly — the variants already pair fill and foreground correctly.
179
198
 
180
- | Token | Use | Tailwind |
181
- | -------------------- | ------------------------ | --------------------- |
182
- | `--status-default` | none / not applicable | `bg-status-default` |
183
- | `--status-neutral` | informational | `bg-status-neutral` |
184
- | `--status-completed` | success, completed | `bg-status-completed` |
185
- | `--status-attention` | warning, needs attention | `bg-status-attention` |
186
- | `--status-danger` | error, blocked | `bg-status-danger` |
199
+ These are fill and indicator colors. `--status-default` follows `--muted-foreground`, so it is theme-dependent and translucent in `cream` and `bloom`; the four hue tokens are opaque in every palette. Do not use them as text color: `--status-completed`, `--status-danger` and `--status-neutral` are below 4.5:1 on a dark card, and `--status-attention` is below 3:1 on a light one. For text, use `text-{intent}-text`.
187
200
 
188
- Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying these directly — the variants already pair fill and foreground correctly. Reach for the raw token only on custom surfaces.
201
+ #### Semantic color roles
202
+
203
+ Each intent has eight roles. The intents are `info`, `success`, `warning`, `danger` and `neutral`. The hue intents read internal color scales; `neutral` reads the system tokens. Every role is a token named `--{intent}-{role}` and a Tailwind color named `{intent}-{role}`:
204
+
205
+ | Role | Tailwind | Use for |
206
+ | --------------- | ----------------------- | ----------------------------------- |
207
+ | `surface` | `bg-info-surface` | Soft background |
208
+ | `surface-hover` | `bg-info-surface-hover` | Hover state of a soft background |
209
+ | `border` | `border-info-border` | Border on a soft background |
210
+ | `solid` | `bg-info-solid` | Filled background |
211
+ | `solid-hover` | `bg-info-solid-hover` | Hover state of a filled background |
212
+ | `text` | `text-info-text` | Text and icons on a soft background |
213
+ | `contrast` | `text-info-contrast` | Text on a filled background |
214
+ | `indicator` | `bg-info-indicator` | Dots and small marks |
215
+
216
+ ```tsx
217
+ <span className="rounded-md bg-success-surface px-2 py-0.5 text-success-text">Paid</span>
218
+ ```
219
+
220
+ In the default palette, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, in light and dark mode, on `--card` and `--background`. A unit test checks this from the shipped CSS. `text` on `surface-hover` is held at 4.2:1 by the same test; it is below 4.5:1 for `danger` and `success` in light mode. The test does not cover the `cream` and `bloom` palettes or the `neutral` intent.
221
+
222
+ Most `surface` and `border` values are translucent tints, so an opacity modifier compounds rather than replaces — `bg-info-surface/50` halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the `warning` surface, surface-hover and border, the `info` border and the `danger` surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent.
223
+
224
+ `Badge`, `Alert`, `CsvImporter` and `MetricCard` read these roles directly, not `--status-*` or `--alert-*`. To recolour them, override the roles. `Badge` `error` and `subtle-error`, and `Alert` `error`, follow `--danger-*`. `Button variant="destructive"`, invalid input borders and error text follow `--danger-*` too, and `--destructive` is an alias of `--danger-solid`.
189
225
 
190
226
  #### Sidebar & charts
191
227
 
@@ -195,24 +231,17 @@ Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `
195
231
 
196
232
  #### Alerts
197
233
 
198
- `--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` — five variants × four slots, each bridged into Tailwind:
234
+ **Deprecated.** `--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` and the matching utilities (`bg-alert-info-background`, `text-alert-info-foreground`, ...) remain for existing code and will be removed in the next major. In the default palette they are aliases of the semantic color roles: `background` is `surface`, `border` is `border`, `foreground` is `text`, and `foreground-muted` follows `foreground`; `error` reads the `danger` intent. The `Alert` component reads the roles, so overriding `--alert-*` changes these utilities but not `Alert`.
199
235
 
200
- | Slot | Tailwind | Use for |
201
- | ------------------ | ---------------------------------- | -------------------------- |
202
- | `background` | `bg-alert-info-background` | Callout fill |
203
- | `foreground` | `text-alert-info-foreground` | Primary text and icons |
204
- | `foreground-muted` | `text-alert-info-foreground-muted` | Secondary/description text |
205
- | `border` | `border-alert-info-border` | Callout border |
206
-
207
- Prefer the `Alert` component — it already pairs the four slots per variant and supplies the icon. Reach for the utilities directly only on custom surfaces `Alert` doesn't cover, such as a status-highlighted table row or an inline note:
236
+ For new custom surfaces, use the roles instead of the alert slots:
208
237
 
209
238
  ```tsx
210
- <div className="rounded-lg border bg-alert-warning-background text-alert-warning-foreground border-alert-warning-border">
239
+ <div className="rounded-lg border bg-warning-surface text-warning-text border-warning-border">
211
240
  …
212
241
  </div>
213
242
  ```
214
243
 
215
- The `background` and `border` slots are already semi-transparent tints (~10% and ~20%), so an opacity modifier compounds rather than replaces — `bg-alert-info-background/50` lands near 5% alpha, not 50%.
244
+ Across all colour tokens:
216
245
 
217
246
  ```tsx
218
247
  // Good — semantic token pairs, and a variant where one exists
@@ -243,29 +272,210 @@ Hand-typing `padding: 13px` is a smell. Round to the nearest scale step; if noth
243
272
 
244
273
  ### Typography
245
274
 
246
- **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`.
275
+ 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`.
276
+
277
+ There are 8 core roles and 2 relaxed body variants:
278
+
279
+ | Role | Utility | Size / line height | Weight | Use |
280
+ | ----------------- | ---------------------- | ------------------ | ------ | ----------------------------------------------- |
281
+ | `heading-lg` | `text-heading-lg` | 24px / 32px | 700 | Page title (`Layout.Header`) |
282
+ | `heading-md` | `text-heading-md` | 18px / 28px | 600 | Section heading, card title |
283
+ | `heading-sm` | `text-heading-sm` | 14px / 20px | 600 | Sub-section heading |
284
+ | `body-md` | `text-body-md` | 14px / 20px | 400 | Body copy, descriptions |
285
+ | `body-sm` | `text-body-sm` | 12px / 16px | 400 | Caption, timestamp, metadata |
286
+ | `label-md` | `text-label-md` | 14px / 20px | 500 | Field label, short UI text, figures in a table |
287
+ | `label-sm` | `text-label-sm` | 12px / 16px | 500 | Small label, badge text |
288
+ | `code-sm` | `text-code-sm` | 12px / 18px | 400 | ID and code. Use with `font-mono` |
289
+ | `body-md-relaxed` | `text-body-md-relaxed` | 14px / 24px | 400 | Long text, such as a chat message or a document |
290
+ | `body-sm-relaxed` | `text-body-sm-relaxed` | 12px / 20px | 400 | Long text at the small size |
291
+
292
+ `heading-lg` also sets letter spacing to `-0.025em`. All other roles set `0em`.
293
+
294
+ Pair a role with a color token from the Color section above. These pairings are the recommended combinations:
295
+
296
+ | Use | Utilities |
297
+ | --------------------------------- | ---------------------------------------------- |
298
+ | Page title (`Layout.Header`) | `text-heading-lg` |
299
+ | Section heading | `text-heading-md` |
300
+ | Card title | `text-heading-md leading-none` |
301
+ | Body copy | `text-body-md` |
302
+ | Secondary copy, descriptions | `text-body-md text-muted-foreground` |
303
+ | Caption, timestamp, metadata | `text-body-sm text-muted-foreground` |
304
+ | Numeric value in a table or field | `text-label-md tabular-nums` |
305
+ | ID, code | `font-mono text-code-sm text-muted-foreground` |
306
+
307
+ <example-preview name="typography-roles"></example-preview>
247
308
 
248
- 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:
309
+ ```tsx
310
+ function TypographyRoles() {
311
+ const rows = [
312
+ {
313
+ role: "heading-lg",
314
+ className: "text-heading-lg",
315
+ en: "Purchase orders",
316
+ ja: "発注管理",
317
+ },
318
+ {
319
+ role: "heading-md",
320
+ className: "text-heading-md",
321
+ en: "Purchase orders by supplier",
322
+ ja: "仕入先別の発注一覧",
323
+ },
324
+ {
325
+ role: "heading-sm",
326
+ className: "text-heading-sm",
327
+ en: "Delivery address",
328
+ ja: "納品先",
329
+ },
330
+ {
331
+ role: "body-md",
332
+ className: "text-body-md",
333
+ en: "The order was sent to the supplier on 12 March.",
334
+ ja: "発注書は3月12日に仕入先へ送付されました。",
335
+ },
336
+ {
337
+ role: "body-sm",
338
+ className: "text-body-sm text-muted-foreground",
339
+ en: "Updated 2 hours ago",
340
+ ja: "2時間前に更新",
341
+ },
342
+ {
343
+ role: "label-md",
344
+ className: "text-label-md",
345
+ en: "Order quantity",
346
+ ja: "発注数量",
347
+ },
348
+ {
349
+ role: "label-sm",
350
+ className: "text-label-sm",
351
+ en: "Draft",
352
+ ja: "下書き",
353
+ },
354
+ {
355
+ role: "code-sm",
356
+ className: "font-mono text-code-sm",
357
+ en: "PO-2026-000184",
358
+ ja: "PO-2026-000184",
359
+ },
360
+ {
361
+ role: "body-md-relaxed",
362
+ className: "text-body-md-relaxed",
363
+ compareWith: { role: "body-md", className: "text-body-md" },
364
+ 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.",
365
+ ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。",
366
+ },
367
+ {
368
+ role: "body-sm-relaxed",
369
+ className: "text-body-sm-relaxed text-muted-foreground",
370
+ compareWith: { role: "body-sm", className: "text-body-sm text-muted-foreground" },
371
+ 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.",
372
+ ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。",
373
+ },
374
+ ];
249
375
 
250
- | Role | Utilities |
251
- | --------------------------------- | ----------------------------------------- |
252
- | Page title (`Layout.Header`) | `text-2xl font-bold tracking-tight` |
253
- | Section heading | `text-lg font-semibold` |
254
- | Card title | `text-lg font-semibold leading-none` |
255
- | Body copy | `text-sm` |
256
- | Secondary copy, descriptions | `text-sm text-muted-foreground` |
257
- | Caption, timestamp, metadata | `text-xs text-muted-foreground` |
258
- | Numeric value in a table or field | `text-sm font-medium tabular-nums` |
259
- | ID, code, keyboard hint | `font-mono text-xs text-muted-foreground` |
376
+ return (
377
+ <div className="flex flex-col gap-4">
378
+ {rows.map(({ role, className, compareWith, en, ja }) => (
379
+ <div key={role} className="grid grid-cols-[9rem_1fr] items-baseline gap-4">
380
+ <span className="font-mono text-code-sm text-muted-foreground">{role}</span>
381
+ {compareWith ? (
382
+ // A relaxed role changes only line height, so show the same text in the base role first.
383
+ <div className="flex flex-col gap-4">
384
+ {[compareWith, { role, className }].map((variant) => (
385
+ <div key={variant.role} className="flex flex-col gap-1">
386
+ <span className="font-mono text-code-sm text-muted-foreground">
387
+ {variant.role}
388
+ </span>
389
+ <p className={variant.className}>{en}</p>
390
+ <p className={variant.className}>{ja}</p>
391
+ </div>
392
+ ))}
393
+ </div>
394
+ ) : (
395
+ <div className="flex flex-col gap-1">
396
+ <p className={className}>{en}</p>
397
+ <p className={className}>{ja}</p>
398
+ </div>
399
+ )}
400
+ </div>
401
+ ))}
402
+ </div>
403
+ );
404
+ }
405
+ ```
260
406
 
261
407
  ```tsx
262
- <h2 className="text-lg font-semibold">Section</h2>
263
- <p className="text-sm text-muted-foreground">Description copy</p>
264
- <span className="text-xs text-muted-foreground">Updated 2h ago</span>
408
+ <h2 className="text-heading-md">Section</h2>
409
+ <p className="text-body-md text-muted-foreground">Description copy</p>
410
+ <span className="text-body-sm text-muted-foreground">Updated 2h ago</span>
265
411
  ```
266
412
 
413
+ 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:
414
+
415
+ ```tsx
416
+ <Text variant="heading-md" render={<h3 />}>Line items</Text>
417
+ <Text variant="body-md" className="text-muted-foreground">Description copy</Text>
418
+ ```
419
+
420
+ 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.
421
+
422
+ **Rules**
423
+
424
+ - **A role carries no color.** Set the color with a token such as `text-muted-foreground`.
425
+ - **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`.
426
+ - **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.
427
+ - **Four modifiers may be combined with a role.** All are stock Tailwind utilities:
428
+ - 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()`.
429
+ - Tabular figures: `tabular-nums`.
430
+ - Emphasis on inline text: `font-semibold`. Do not use `font-medium`; on a label role it makes no change.
431
+ - Uppercase: `uppercase`.
432
+ - **Do not combine a role with other `leading-*`, `tracking-*`, or `text-[Npx]` values.** If none of the roles fits, use the nearest role.
433
+ - **12px is the minimum size.** Do not use smaller text.
434
+ - **`code-sm` needs `font-mono`.** The role sets size, line height, weight, and letter spacing, but a role cannot set the font family.
435
+ - **Use the relaxed variants for long text only.** For labels, table cells, and other short text, use the regular roles.
436
+
267
437
  Always use `tabular-nums` for numbers that stack in a column — without it, digits jitter between rows.
268
438
 
439
+ **Change a role.** Each role is four CSS variables named `--type-<role>-<size|line-height|weight|letter-spacing>`. 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. 1.17.0 named these variables `--app-shell-type-*`. The old names still work, but they are deprecated and will be removed in the next major; rename them to `--type-*`.
440
+
441
+ ```css
442
+ :root {
443
+ --type-heading-lg-size: 1.75rem;
444
+ --type-heading-lg-line-height: calc(36 / 28);
445
+ }
446
+ ```
447
+
448
+ 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`: `<h3 className="text-heading-md 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.
449
+
450
+ **`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.
451
+
452
+ ```ts
453
+ import { extendTailwindMerge } from "tailwind-merge";
454
+
455
+ const twMerge = extendTailwindMerge({
456
+ extend: {
457
+ classGroups: {
458
+ "font-size": [
459
+ {
460
+ text: [
461
+ "heading-lg",
462
+ "heading-md",
463
+ "heading-sm",
464
+ "body-md",
465
+ "body-sm",
466
+ "body-md-relaxed",
467
+ "body-sm-relaxed",
468
+ "label-md",
469
+ "label-sm",
470
+ "code-sm",
471
+ ],
472
+ },
473
+ ],
474
+ },
475
+ },
476
+ });
477
+ ```
478
+
269
479
  ### Radius
270
480
 
271
481
  `--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.
@@ -332,12 +542,12 @@ Never invent a z value — `z-index: 9999` is always wrong. Popups and overlays
332
542
 
333
543
  **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:
334
544
 
335
- | Text size | Icon class |
336
- | ------------------ | ---------- |
337
- | `text-xs` | `size-3` |
338
- | `text-sm` | `size-4` |
339
- | `text-lg` | `size-5` |
340
- | `text-2xl` (title) | `size-6` |
545
+ | Text size | Icon class |
546
+ | ------------------------------------------------------------------- | ---------- |
547
+ | 12px: `text-xs`, `text-body-sm`, `text-label-sm`, `text-code-sm` | `size-3` |
548
+ | 14px: `text-sm`, `text-body-md`, `text-label-md`, `text-heading-sm` | `size-4` |
549
+ | 18px: `text-lg`, `text-heading-md` | `size-5` |
550
+ | 24px (title): `text-2xl`, `text-heading-lg` | `size-6` |
341
551
 
342
552
  ```tsx
343
553
  import { Check } from "lucide-react";
@@ -462,12 +672,12 @@ Write **plain Tailwind utilities** in your application code. Three rules cover e
462
672
  </div>
463
673
  ```
464
674
 
465
- When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below), and `!` is what the pattern catalogue uses for the one documented case, zeroing a card's padding so a table can sit flush inside it:
675
+ When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below). Check for a prop before reaching for it. Sitting a table flush inside a card, for example, used to need `className="px-0!"`; it is now a prop:
466
676
 
467
677
  ```tsx
468
678
  <Card.Root>
469
679
  <Card.Header title="Line items" />
470
- <Card.Content className="px-0!">
680
+ <Card.Content padding="none">
471
681
  <Table.Root>{/* … */}</Table.Root>
472
682
  </Card.Content>
473
683
  </Card.Root>
@@ -494,6 +704,8 @@ This is the reason rule 3 sends you to props rather than to a more specific clas
494
704
  <Card.Content className="px-0" />
495
705
  ```
496
706
 
707
+ (For this particular case, use `padding="none"`.)
708
+
497
709
  State variants make it worse rather than better: `Button`'s `ghost` variant sets a hover color, and a `:hover` rule outranks an unprefixed utility on **specificity**, not merely order — so a plain `text-destructive` on a ghost button is red only until the pointer reaches it. `variant="destructive"` is the supported way to express that; where no such prop exists, `!` wins in every state because importance beats specificity.
498
710
 
499
711
  ### Styling by state (data attributes)
@@ -503,7 +715,7 @@ AppShell's UI components support data-attribute-based styling, following the [Ba
503
715
  ```css
504
716
  /* Style a component based on its state */
505
717
  .SwitchThumb[data-checked] {
506
- background-color: var(--status-completed);
718
+ background-color: var(--success-solid);
507
719
  }
508
720
 
509
721
  .MenuItem[data-highlighted] {
@@ -573,10 +785,11 @@ These are visual-composition rules every screen must follow, regardless of patte
573
785
 
574
786
  | Intent | Pick |
575
787
  | ---------------------------------- | ------------------------------------------------------------------------------------------------------- |
576
- | Destructive action (delete, void) | `Button variant="destructive"`; `bg-destructive` on custom surfaces; confirm in a dialog at `shadow-lg` |
577
- | Non-blocking caution | `Badge variant="warning"`, or `bg-status-attention` |
578
- | Confirmation / completed state | `Badge variant="success"`, or `bg-status-completed` |
579
- | Neutral callout | `Badge variant="info"`, or the `Alert` component |
788
+ | Destructive action (delete, void) | `Button variant="destructive"`; `bg-danger-solid` on custom surface; confirm in a dialog at `shadow-lg` |
789
+ | Non-blocking caution | `Badge variant="warning"`, or `bg-warning-surface text-warning-text` |
790
+ | Confirmation / completed state | `Badge variant="success"`, or `bg-success-surface text-success-text` |
791
+ | Informational callout | `Badge variant="info"`, or `Alert variant="info"` |
792
+ | Neutral callout | `Badge variant="neutral"`, or `Alert variant="neutral"` |
580
793
  | Persistent panel (sidebar, header) | `shadow-sm` |
581
794
  | Hovered / sticky surface | `shadow-md` |
582
795
  | Popover / menu / tooltip | `bg-popover`, `shadow-md`, `duration-150` |
@@ -584,5 +797,5 @@ These are visual-composition rules every screen must follow, regardless of patte
584
797
  | Hover / focus transition | `duration-150 ease-out` |
585
798
  | State change (toggle, select) | `duration-200 ease-in-out` |
586
799
  | Two-column detail at <1024 | right column collapses below main — do not override |
587
- | Inline ID, code, table number | `font-mono text-xs` (identifiers) / `tabular-nums` (figures) |
588
- | Timestamp, label, subtle metadata | `text-xs text-muted-foreground` |
800
+ | Inline ID, code, table number | `font-mono text-code-sm` (identifiers) / `text-label-md tabular-nums` (figures) |
801
+ | Timestamp, label, subtle metadata | `text-body-sm text-muted-foreground` |