@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
@@ -10,6 +10,8 @@ Each entry states which versions are affected, what breaks, how to detect it, an
10
10
 
11
11
  | Version | Change |
12
12
  | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
13
+ | 1.18.0 | [Destructive colours now resolve to the danger roles](#1180-destructive-colours-now-resolve-to-the-danger-roles) |
14
+ | 1.17.0 | [Status and alert tokens now resolve to the colour roles](#1170-status-and-alert-tokens-now-resolve-to-the-colour-roles) |
13
15
  | 1.15.0 | [Base UI 1.8.0 breaks closed-`Select` and dialog-click tests](#1150-base-ui-180-breaks-closed-select-and-dialog-click-tests) |
14
16
  | 1.12.0 | [`DateField` / `DatePicker` field chrome moved to `Field.Root`](#1120-datefield--datepicker-field-chrome-moved-to-fieldroot) |
15
17
  | 1.11.0 | [React 19.2.7 and React Router v8 required](#1110-react-1927-and-react-router-v8-are-now-required) |
@@ -21,6 +23,46 @@ Each entry states which versions are affected, what breaks, how to detect it, an
21
23
  | 1.0.2 | [`Toaster` no longer accepts `richColors`](#102-toaster-no-longer-accepts-richcolors) |
22
24
  | before 1.0 | [Pre-1.0 breaking changes](#before-10) |
23
25
 
26
+ ## 1.18.0: destructive colours now resolve to the danger roles
27
+
28
+ **Applies to:** apps that show `Button variant="destructive"` in dark mode, apps that use `text-destructive`, `border-destructive` or `bg-destructive` on their own elements, and apps whose theme overrides `--destructive` or `--destructive-foreground`.
29
+
30
+ 1.18.0 makes `--destructive` and `--destructive-foreground` aliases of `--danger-solid` and `--danger-contrast`. The names, `Button variant="destructive"` and `text-destructive` keep working. Nothing errors. `--destructive`, `--destructive-foreground` and their utilities are deprecated and will be removed in the next major; `Button variant="destructive"` will be renamed to `danger` then. Three things change silently:
31
+
32
+ - **Dark-mode `Button variant="destructive"` is a full `#dc2626` fill.** It was `#f87171` at 60% opacity. The text is `--danger-contrast` (white) and the hover fill is `--danger-solid-hover` instead of 90% opacity. Light mode keeps `#dc2626`.
33
+ - **`text-destructive` resolves to the solid.** In dark mode it changes from `#f87171` to `#dc2626`, which is 3.71:1 on the dark card, below 4.5:1 for text. Components shipped with AppShell now use `text-danger-text` for error text, and invalid borders and focus rings read `--danger-solid`. In dark mode the invalid border changes from `#f87171` to `#dc2626`.
34
+ - **Themes that override `--destructive` no longer reach the components.** `Button`, invalid `Input`, `Textarea`, `Checkbox`, `Field` and `DateField` borders and rings, error text and the `CsvImporter` error cells read `--danger-*`. An override of `--destructive` still changes `bg-destructive` and `text-destructive` on your own elements, so the two reds can drift apart. The `cream` and `bloom` palettes no longer set `--destructive`; their values were the same as the default.
35
+
36
+ How to detect it: search the app for `destructive` in class names and in theme CSS, including Theme Generator output that sets `--destructive`. Compare `Button variant="destructive"`, an invalid `Input` and an error message in light and dark mode after upgrading.
37
+
38
+ What to change:
39
+
40
+ - For text, replace `text-destructive` with `text-danger-text`. For fills, replace `bg-destructive` with `bg-danger-solid`, and `text-destructive-foreground` with `text-danger-contrast`.
41
+ - For tints and borders, use `bg-danger-surface` and `border-danger-border` instead of `bg-destructive/10` and `border-destructive/30`.
42
+ - In a theme that recolours destructive actions, set `--danger-solid` (and `--danger-solid-hover`, `--danger-text`, `--danger-contrast` as needed) instead of `--destructive`. Remove `--destructive` and `--destructive-foreground` from the theme.
43
+
44
+ The roles are documented in [Styling & theming](https://github.com/tailor-platform/app-shell/blob/main/docs/concepts/styling-theming.md#brand--action). The decision record is `decisions/semantic-color-roles.md`.
45
+
46
+ ## 1.17.0: status and alert tokens now resolve to the colour roles
47
+
48
+ **Applies to:** apps that use `bg-status-*`, `text-status-*` or `bg-alert-*` utilities, and apps whose theme overrides `--status-*`, `--alert-*` or `--destructive` to recolour `Badge`, `Alert`, `CsvImporter` or `MetricCard`.
49
+
50
+ 1.17.0 adds semantic colour roles, `--{info|success|warning|danger|neutral}-{surface|surface-hover|border|solid|solid-hover|text|contrast|indicator}`, bridged as Tailwind colours such as `bg-success-surface` and `text-danger-text`. The old `--status-*` and `--alert-*` tokens still exist and still work, but they no longer hold their own colours: each one now resolves to a role (for example `--status-completed` is `var(--success-solid)`). They are deprecated and will be removed in the next major. Nothing errors. Three things change silently:
51
+
52
+ - **Colours change.** `bg-status-*` and `bg-alert-*` resolve to the role values. For example `bg-status-completed` is `#15803d` instead of `#22c55e`. `--status-default` follows `--muted-foreground` instead of a fixed `#737373`.
53
+ - **Component recolouring moves to the roles.** `Badge`, `Alert`, `CsvImporter` and `MetricCard` read the roles directly. A theme that overrides `--status-*` or `--alert-*` no longer changes these components; override `--{intent}-*` instead. `Badge` `error` / `subtle-error` and `Alert` `error` read `--danger-*`, so overriding `--destructive` no longer reaches them either. `Button variant="destructive"` and `text-destructive` still follow `--destructive`.
54
+ - **`text-status-*` loses contrast in dark mode.** The `--status-*` values are now the step-9 fills, which are below 4.5:1 as text on the dark card.
55
+
56
+ How to detect it: search the app for `status-`, `alert-` and theme overrides of `--status-`, `--alert-` and `--destructive`. Compare `Badge` and `Alert` in light and dark mode after upgrading.
57
+
58
+ What to change:
59
+
60
+ - Replace `text-status-completed` with `text-success-text`, `text-status-attention` with `text-warning-text`, `text-status-danger` with `text-danger-text`, and `text-status-neutral` with `text-info-text`.
61
+ - Replace `bg-status-completed/10 border-status-completed/30` style tints with `bg-success-surface border-success-border`. The same applies to the other intents and to `bg-alert-*` / `border-alert-*`.
62
+ - In a theme that recolours the status components, set the roles (`--success-solid`, `--danger-text`, ...) instead of `--status-*`, `--alert-*` or `--destructive`.
63
+
64
+ The roles are documented in [Styling & theming](https://github.com/tailor-platform/app-shell/blob/main/docs/concepts/styling-theming.md#semantic-color-roles). The decision record is `decisions/semantic-color-roles.md`.
65
+
24
66
  ## 1.15.0: Base UI 1.8.0 breaks closed-`Select` and dialog-click tests
25
67
 
26
68
  **Applies to:** apps with unit tests that assert `Select` options without opening the `Select`, and apps with Playwright tests that click buttons inside a `Dialog` or `AlertDialog`.
@@ -158,7 +200,7 @@ body {
158
200
 
159
201
  Toggle dark mode and confirm a real surface changes: inspect a `Card` and watch its computed `background-color` go from `rgb(255, 255, 255)` to `rgb(23, 23, 23)` on the default palette.
160
202
 
161
- Reading the token directly also works — `getComputedStyle(document.documentElement).getPropertyValue("--card")` returns the winning declaration, so a stale copy shows up as its own value. Just compare against the authored notation: AppShell writes `rgba(23, 23, 23, 1)`, not `#171717`, and the computed value preserves that form.
203
+ Reading the token directly also works — `getComputedStyle(document.documentElement).getPropertyValue("--card")` returns the winning declaration, so a stale copy shows up as its own value. Compare against the value the default palette resolves to: in dark mode `--card` returns `#171717`, the gray step it reads. (1.17.0 and earlier return `rgba(23, 23, 23, 1)`.) A copy in your own CSS shows up in the notation you wrote.
162
204
 
163
205
  ## 1.5.0: `loader` removed from file-based page definitions
164
206
 
@@ -183,7 +183,7 @@ time, and the record is unblocked only once every one is released.
183
183
  ### 4. Line items
184
184
 
185
185
  `Card.Root` → `Card.Header` (title, plus a one-line description where the
186
- columns need explaining) → `Card.Content className="px-0!"` → `Table.Root`.
186
+ columns need explaining) → `Card.Content padding="none"` → `Table.Root`.
187
187
 
188
188
  Fetch the line items with the record, sorted explicitly so the order is stable
189
189
  across reloads, and render them as a plain `Table`. For the typical document —
@@ -773,7 +773,7 @@ export default function PurchaseOrderDetailPage({
773
773
  would have been a link field on the summary instead. */}
774
774
  <Card.Root>
775
775
  <Card.Header title="Source requisitions" />
776
- <Card.Content className="px-0!">
776
+ <Card.Content padding="none">
777
777
  <Table.Root>
778
778
  <Table.Header>
779
779
  <Table.Row>
@@ -808,7 +808,7 @@ export default function PurchaseOrderDetailPage({
808
808
  enough to page. No container padding; the cells inset themselves. */}
809
809
  <Card.Root>
810
810
  <Card.Header title="Line items" />
811
- <Card.Content className="px-0!">
811
+ <Card.Content padding="none">
812
812
  {order.lineItems.length === 0 ? (
813
813
  <p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
814
814
  ) : (
@@ -875,7 +875,7 @@ export default function PurchaseOrderDetailPage({
875
875
  relationship is possible, so the reader learns it exists. */}
876
876
  <Card.Root>
877
877
  <Card.Header title="Goods receipts" />
878
- <Card.Content className="px-0!">
878
+ <Card.Content padding="none">
879
879
  {order.goodsReceipts.length === 0 ? (
880
880
  <p className="px-6 text-sm text-muted-foreground">
881
881
  No goods receipts linked to this order.
@@ -997,7 +997,7 @@ export default function PurchaseOrderDetailPage({
997
997
  title="Journal entry"
998
998
  description="Booked when this order was posted."
999
999
  />
1000
- <Card.Content className="px-0!">
1000
+ <Card.Content padding="none">
1001
1001
  <Table.Root>
1002
1002
  <Table.Header>
1003
1003
  <Table.Row>
@@ -1096,7 +1096,7 @@ enforced across every detail screen once settled.
1096
1096
  ## Constraints
1097
1097
 
1098
1098
  - **Every main-column section sits in a `Card.Root`** — except `DescriptionCard`, which contains itself, and `Alert`, which is a banner. No bare `<div>` sections.
1099
- - **A table in a card needs ONE geometry change, not two.** Zero the card's padding (`Card.Content className="px-0!"`, or drop `Card.Content`) and leave the table container alone. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title.
1099
+ - **A table in a card goes in `Card.Content padding="none"`, and nothing else changes.** Don't drop `Card.Content` or zero its padding with a class instead: the prop also keeps row backgrounds inside the card's rounded corners and removes a nested `DataTable`'s own border. Leave the table container alone too. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title.
1100
1100
  - **Bare `YYYY-MM-DD` dates must not use `type: "date"`.** `DescriptionCard` hands the value to `new Date(...)`, which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text via `render`. Real timestamps keep `type: "date"`, with `emptyBehavior: "hide"` when nullable.
1101
1101
  - **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them.
1102
1102
  - **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines.
@@ -28,32 +28,36 @@ function ModalForm() {
28
28
  <Dialog.Title>Add address</Dialog.Title>
29
29
  <Dialog.Description>Add a shipping address to this order.</Dialog.Description>
30
30
  </Dialog.Header>
31
- <form
32
- onSubmit={(event) => {
33
- event.preventDefault();
34
- const data = new FormData(event.currentTarget);
35
- window.alert(`Saving ${data.get("label") ?? ""}`);
36
- }}
31
+ {/*
32
+ * `onFormSubmit` fires only after validation passes and receives the
33
+ * registered field values — no `<form onSubmit>` + `new FormData(...)`.
34
+ */}
35
+ <Form<AddressDraft>
36
+ noValidate
37
+ onFormSubmit={(values) => window.alert(`Saving ${values.label}`)}
37
38
  >
38
39
  <div className="flex flex-col gap-4 py-4">
39
40
  <Field.Root name="label">
40
41
  <Field.Label>Label</Field.Label>
41
- <Field.Control render={<Input />} />
42
+ <Field.Control required />
43
+ <Field.Error match="valueMissing">Label is required.</Field.Error>
42
44
  </Field.Root>
43
45
  <Field.Root name="street">
44
46
  <Field.Label>Street</Field.Label>
45
- <Field.Control render={<Input />} />
47
+ <Field.Control required />
48
+ <Field.Error match="valueMissing">Street is required.</Field.Error>
46
49
  </Field.Root>
47
50
  <Field.Root name="city">
48
51
  <Field.Label>City</Field.Label>
49
- <Field.Control render={<Input />} />
52
+ <Field.Control required />
53
+ <Field.Error match="valueMissing">City is required.</Field.Error>
50
54
  </Field.Root>
51
55
  </div>
52
56
  <Dialog.Footer>
53
- <Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
57
+ <Dialog.Close render={<Button type="button" variant="ghost" />}>Cancel</Dialog.Close>
54
58
  <Button type="submit">Save</Button>
55
59
  </Dialog.Footer>
56
- </form>
60
+ </Form>
57
61
  </Dialog.Content>
58
62
  </Dialog.Root>
59
63
  );
@@ -86,27 +90,28 @@ function ModalFormRouted() {
86
90
  <Dialog.Header>
87
91
  <Dialog.Title>Create product</Dialog.Title>
88
92
  </Dialog.Header>
89
- <form
90
- onSubmit={(event) => {
91
- event.preventDefault();
92
- const data = new FormData(event.currentTarget);
93
- window.alert(`Saving ${data.get("name") ?? ""}`);
93
+ <Form<ProductDraft>
94
+ noValidate
95
+ onFormSubmit={(values) => {
96
+ window.alert(`Saving ${values.name}`);
94
97
  setCreateOpen(false);
95
98
  }}
96
99
  >
97
100
  <div className="flex flex-col gap-4 py-4">
98
101
  <Field.Root name="name">
99
102
  <Field.Label>Name</Field.Label>
100
- <Field.Control render={<Input />} />
103
+ <Field.Control required />
104
+ <Field.Error match="valueMissing">Name is required.</Field.Error>
101
105
  </Field.Root>
102
106
  </div>
103
107
  <Dialog.Footer>
104
- <Button variant="ghost" onClick={() => setCreateOpen(false)}>
108
+ {/* Inside a `<Form>` an untyped button submits — Cancel must be `type="button"`. */}
109
+ <Button type="button" variant="ghost" onClick={() => setCreateOpen(false)}>
105
110
  Cancel
106
111
  </Button>
107
112
  <Button type="submit">Save</Button>
108
113
  </Dialog.Footer>
109
- </form>
114
+ </Form>
110
115
  </Dialog.Content>
111
116
  </Dialog.Root>
112
117
  </Layout>
@@ -133,7 +138,8 @@ placeholder.
133
138
  - **`Form` + `Field` is the default stack.** They wrap Base UI and ship with AppShell — no extra
134
139
  dependency.
135
140
  - **Submit via `onFormSubmit(values)`.** It fires only after validation passes. Do not hand-roll
136
- `<form onSubmit>` + `new FormData(...)` — that skips validation and server-error routing.
141
+ `<form onSubmit>` + `new FormData(...)` — that bypasses `Form`'s field validation and server-error
142
+ routing.
137
143
  - **`onFormSubmit` reads registered `Field.Root`s, not the DOM.** So every control — including
138
144
  `Select`, `Combobox`, and `Autocomplete` — just needs wrapping in a `Field.Root name="…"`. They
139
145
  need **no `name` of their own and no `useState`**. Mirroring field values into React state is the
@@ -0,0 +1,139 @@
1
+ ---
2
+ title: i18n Labels
3
+ description: Decide UI language before implementation and keep display labels separate from stable data
4
+ ---
5
+
6
+ <!-- GENERATED by docs-kit from docs-src/patterns/i18n-labels.docs.outline.md — do not edit by hand. -->
7
+
8
+ # i18n Labels
9
+
10
+ ## When to Use
11
+
12
+ Read this cross-cutting pattern **before implementing a screen**, alongside its page or layout pattern. It applies to single-language apps as well as apps with a language selector. For the complete API and fallback rules, see [defineI18nLabels](../api/define-i18n-labels.md).
13
+
14
+ ## Decide the UI Language First
15
+
16
+ Record the UI language in the screen or app specification independently of the language used to write the document. An English specification, field name, or enum does not imply English UI copy.
17
+
18
+ For a Japanese-only app, explicitly pass `locale="ja"` (or `"ja-JP"`) to the root `AppShell`; do not rely on each developer's browser preferences. Keep the same policy across navigation, headings, forms, dialogs, accessibility labels, empty/loading/error states, validation, and notifications. Browser detection is appropriate only when that is the intended product behavior.
19
+
20
+ Use `defineI18nLabels` by default, including for single-language apps, to avoid a later screen-by-screen extraction. Its current API always requires an `en` base, even when the app selects `ja`. A project may explicitly opt out of catalog-based labels for a deliberately single-language app; record that decision and keep its chosen UI language consistent. AppShell does not force catalog adoption or generate missing translations.
21
+
22
+ ## Key Granularity and Naming
23
+
24
+ - Use a **flat** map with semantic dotted keys, such as `"requests.list.title"`, `"requests.actions.approve"`, and `"requests.status.PENDING"`. Dots are a naming convention, not nested objects.
25
+ - Name the key after its purpose, not the current wording. Copy edits should not rename keys.
26
+ - One key represents one independently translated message. Translate a whole sentence using typed props rather than joining fragments, so each language controls word order and pluralization.
27
+ - Share genuinely equivalent operations such as `"actions.cancel"`; do not reuse a key merely because two English strings currently happen to match. A navigation group, page heading, and permission description can require different wording.
28
+ - Keep IDs, route segments, field names, enum values, and comparison values untranslated. Translate only their user-facing presentation. User-entered content and proper names are data, not catalog keys.
29
+
30
+ ## Split by Feature, Not by Locale
31
+
32
+ Start with one `i18n-labels.ts` per feature, containing all of that feature's supported locales. Keep a small shared catalog for genuinely common copy. Export each factory result and its Hook; alias the Hook at import sites when a component needs several catalogs.
33
+
34
+ ```text
35
+ src/
36
+ i18n-labels.ts # shared actions and generic feedback
37
+ features/
38
+ requests/
39
+ i18n-labels.ts # request keys, with en + ja together
40
+ request-list.tsx
41
+ users/
42
+ i18n-labels.ts # user keys, with en + ja together
43
+ ```
44
+
45
+ Avoid a global catalog that every feature must edit, but do not create a separate catalog for every small component. Keep definitions at module scope, outside React render functions. Do not add a new wrapper, global translator, or dictionary-merging framework: each `defineI18nLabels` result already supplies its own typed resolvers.
46
+
47
+ ## Example: Stable Status, Localized Display
48
+
49
+ The shared setup for the runnable example is a feature catalog in `features/requests/i18n-labels.ts`:
50
+
51
+ ```tsx
52
+ import { defineI18nLabels } from "@tailor-platform/app-shell";
53
+
54
+ export const labels = defineI18nLabels({
55
+ en: {
56
+ "requests.list.title": "Requests",
57
+ "requests.actions.approve": "Approve",
58
+ "requests.status.PENDING": "Pending approval",
59
+ "requests.status.APPROVED": "Approved",
60
+ "requests.status.unknown": "Unknown status",
61
+ },
62
+ ja: {
63
+ "requests.list.title": "申請一覧",
64
+ "requests.actions.approve": "承認",
65
+ "requests.status.PENDING": "承認待ち",
66
+ "requests.status.APPROVED": "承認済み",
67
+ "requests.status.unknown": "不明な状態",
68
+ },
69
+ });
70
+
71
+ export const useRequestT = labels.useT;
72
+ ```
73
+
74
+ Import `useRequestT` from that module in the screen below. Here, `request` stands in for a validated server record; its ID and status remain unchanged in every locale.
75
+
76
+ <example-preview name="localized-request"></example-preview>
77
+
78
+ ```tsx
79
+ function LocalizedRequest() {
80
+ const t = useRequestT();
81
+ const request = { id: "REQ-1042", status: "PENDING" };
82
+ const canApprove = request.status === "PENDING";
83
+
84
+ return (
85
+ <Layout>
86
+ <Layout.Header
87
+ title={t("requests.list.title")}
88
+ actions={[
89
+ <Button key="approve" disabled={!canApprove}>
90
+ {t("requests.actions.approve")}
91
+ </Button>,
92
+ ]}
93
+ />
94
+ <Layout.Column>
95
+ <div className="flex items-center gap-2">
96
+ <span>{request.id}</span>
97
+ <Badge variant={canApprove ? "warning" : "success"}>
98
+ {t.dynamic(`requests.status.${request.status}`, t("requests.status.unknown"))}
99
+ </Badge>
100
+ </div>
101
+ </Layout.Column>
102
+ </Layout>
103
+ );
104
+ }
105
+ ```
106
+
107
+ The comparison uses `"PENDING"`, never `t("requests.status.PENDING")`. The runtime-key fallback is itself translated. For a known key, prefer `t(key)` to `t.dynamic()` so TypeScript checks it. For a function-valued label, use `t(key, props)`; `t.dynamic()` cannot supply props.
108
+
109
+ Navigation definitions use the same catalog's `labels.t("requests.list.title")`, not the component Hook. Pass it to `meta.title` or `meta.breadcrumbTitle` so AppShell resolves it with its current locale. Other component props that accept only strings use the Hook's `t()` instead.
110
+
111
+ ## Server-originated Text
112
+
113
+ Keep **machine-readable meaning** separate from **display copy**:
114
+
115
+ | Server data | Frontend treatment |
116
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
117
+ | Stable enum or status code | Keep the value unchanged; map it to a display label |
118
+ | Domain error code plus structured parameters | Validate the response, select a known label by code, and pass parameters to a function-valued label |
119
+ | Unknown error code or unexpected response shape | Show a localized generic failure message; preserve a safe support identifier when available |
120
+ | User-authored text, identifiers, or proper names | Display as data, not as a label key |
121
+ | English prose with no usable code/parameters | It cannot be reliably localized or used for matching; show a localized fallback and treat structured errors as a backend prerequisite |
122
+
123
+ For example, an `APPROVAL_ROUTE_BLOCKED` code plus a separate `routeName` parameter can select a message like `"requests.errors.routeBlocked"` with `{ routeName }`. Do not find the route name by parsing an English sentence, translate arbitrary server text as a catalog key, or compare translated error messages to decide behavior.
124
+
125
+ If a transport collapses all domain codes into `PIPELINE_ERROR`, the frontend cannot recover the lost meaning. Fixing error propagation is a platform concern, not something this pattern or `defineI18nLabels` solves. Do not pretend that a generic code identifies a specific domain failure. Avoid showing raw technical error details to end users.
126
+
127
+ ## Validation and E2E
128
+
129
+ - Compile catalogs and call sites: explicitly declared locales must supply the English base keys and compatible props. This checks structure, not the language or quality of the wording.
130
+ - Verify the intended locale in the browser, including navigation, dialogs, empty/error states, accessibility names, notifications, and unknown-code fallbacks. Do not assume successful type checks prove the absence of mixed-language UI.
131
+ - Test business state with stable codes. Test rendered labels in the selected locale; do not leave English display expectations in Japanese UI tests.
132
+ - Scope E2E locators by role and region, such as a heading in `main` or a button inside a named dialog. A translated phrase can legitimately occur in the sidebar, breadcrumb, and page; an unscoped text locator can become ambiguous.
133
+ - When adding a locale, review the app's catalogs **and** built-in component coverage. Custom catalog translations do not automatically translate AppShell's built-in labels.
134
+
135
+ ## Related
136
+
137
+ - [defineI18nLabels](../api/define-i18n-labels.md) — `useT`, `t.dynamic`, function props, metadata, and fallback behavior
138
+ - [useResolvedLocale](../api/use-resolved-locale.md) — full locale for date and number formatting
139
+ - [Sidebar Navigation](../concepts/sidebar-navigation.md) — localized groups, sections, and item titles