@stapel/workspaces-react 0.17.0 → 0.19.1

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 (123) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +109 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +143 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +1 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/selection.d.ts +15 -0
  84. package/dist/model/selection.d.ts.map +1 -1
  85. package/dist/model/selection.js +18 -1
  86. package/dist/model/selection.js.map +1 -1
  87. package/dist/nav/manifest.d.ts +45 -0
  88. package/dist/nav/manifest.d.ts.map +1 -0
  89. package/dist/nav/manifest.js +77 -0
  90. package/dist/nav/manifest.js.map +1 -0
  91. package/llms.txt +11 -10
  92. package/manifest.json +281 -69
  93. package/package.json +18 -12
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +187 -60
  96. package/src/default/ActiveWorkspace.tsx +97 -0
  97. package/src/default/AuditTrailPane.tsx +309 -0
  98. package/src/default/InvitationsPane.tsx +501 -0
  99. package/src/default/InviteAcceptPage.tsx +187 -90
  100. package/src/default/MembersManager.tsx +832 -222
  101. package/src/default/RoleSelectField.tsx +187 -0
  102. package/src/default/WorkspaceSettings.tsx +540 -125
  103. package/src/default/WorkspacesPage.tsx +394 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +315 -0
  106. package/src/headless/Members.tsx +84 -16
  107. package/src/headless/RoleSelect.tsx +12 -5
  108. package/src/i18n/es.ts +326 -14
  109. package/src/i18n/generated/errors.es.gen.ts +5 -0
  110. package/src/i18n/generated/errors.gen.ts +15 -0
  111. package/src/i18n/generated/errors.json +37 -0
  112. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  113. package/src/i18n/keys.ts +462 -5
  114. package/src/i18n/ru.ts +259 -5
  115. package/src/index.ts +1 -0
  116. package/src/model/format.ts +158 -0
  117. package/src/model/selection.tsx +19 -1
  118. package/src/nav/manifest.ts +120 -0
  119. package/dist/default/ErrorAlert.d.ts +0 -8
  120. package/dist/default/ErrorAlert.d.ts.map +0 -1
  121. package/dist/default/ErrorAlert.js +0 -30
  122. package/dist/default/ErrorAlert.js.map +0 -1
  123. package/src/default/ErrorAlert.tsx +0 -47
package/CHANGELOG.md CHANGED
@@ -1,5 +1,223 @@
1
1
  # @stapel/workspaces-react
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f79bdc3: tokens-antd: a gated control is semantically off and interactively ALIVE — it can be tapped, focused, and can say why it will not do the thing
8
+
9
+ `GatedControl` handed callers `bind.disabled` and its own JSDoc told them to spread it straight onto the control. That produced an html-`disabled` element, which fires no events in any browser: it cannot be clicked, cannot take focus, cannot be described to a screen reader that never reaches it, and cannot carry the one gesture that mattered — the tap that should open the sign-in door standing behind the gate. Every gated control across the ~20 pairs using it was inert, and the wrong instruction was half the defect: the docs taught the shape that broke it.
10
+
11
+ Measured on a live deployment: an anonymous visitor taps the favourite heart and nothing happens at all — no sentence, no tooltip, no door (walker defects D45/D72).
12
+
13
+ **The corrected contract.** While the gate is shut a control is now `aria-disabled="true"` and NOT html-disabled, so it stays focusable and keeps receiving events. The ACTION is suppressed by `GatedControl` itself, in a capture-phase wrapper (`display: contents`, so no pair's layout moves by a pixel): the caller's `onClick`, keyboard activation, typing, IME input, paste and drop are swallowed before the control sees them. Callers write their handlers exactly as if the gate did not exist. The activation comes back as the new `onBlockedActivate`, which is where a pair opens its door. The reason stays where it was — visible text wired by `aria-describedby` — and where a `PaneGate` pools it into one footnote, the gesture now brings a `role="status"` copy of the sentence back to the control it belongs to. A blocked `GatedButton` keeps antd's exact disabled paint (its own `-disabled` class, which sets no `pointer-events`), so nothing about any screen looks different.
14
+
15
+ `GatedControlProps.whenBlocked` holds the two deliberate opt-outs, neither of them the default:
16
+
17
+ - `"inert"` — html `disabled`, for the rare control that must be switched off at the browser level. `attributes-react`'s catalogue lock is the one place in the fleet that asks for it, and now says so.
18
+ - `"annotate"` — the control stays fully usable and only gains the sentence, for a gate that judges the VALUE rather than refusing the person: `calendar-react`'s slot-length field must stay editable, because editing it is how the reason goes away, and `search-react`'s sort must still pick the options that are not the blocked one.
19
+
20
+ `useBlockedButtonClassName()` is exported for render-prop call sites that paint their own button and want the same unavailable look rather than a second grey.
21
+
22
+ **⚠️ The readiness-signal hazard, and its cure.** `element.disabled` is now permanently `false` on every gated control in the fleet. Any test using it as a readiness signal — `await waitFor(() => expect(save.disabled).toBe(false))`, meaning "wait until this is allowed" — returns instantly and mis-times SILENTLY: every assertion after it reads an unseeded component, and the failure looks like broken product logic rather than a gate that had not opened. One pair's suite went green → 21 failures across unrelated files on exactly this. Wait on the stamp instead, which is what such a wait was always asking:
23
+
24
+ ```ts
25
+ await waitFor(() =>
26
+ expect(
27
+ screen.getByTestId("save-gate").getAttribute("data-stapel-gated")
28
+ ).toBe("available")
29
+ );
30
+ ```
31
+
32
+ `data-stapel-gated="available" | "blocked"` is on the wrapper of every gated control in all three modes (`GatedButton` names it `<testId>-gate`). For a point assertion on one element, read `aria-disabled`. Never `disabled`.
33
+
34
+ **ChoiceChips** carried the same defect on its own chips and is fixed the same way: a chip at the cap is `aria-disabled` and focusable, and the tap is refused in the handler, so the row's sentence reaches a keyboard.
35
+
36
+ **The consumers.** Every `GatedButton` call site (64 imports across 20 pairs) is fixed with no code change — the correction is in the substrate. The render-prop call sites that consumed the binding field-by-field now spread it whole: `billing-react`'s auto-recharge switch, `calendar-react`'s RSVP buttons, `moderation-react`'s sanction checkbox, `notifications-react`'s push switch, `attributes-react`'s at-max add button. `tasks-react`'s assignee picker is a host slot rendering its own control out of reach of the suppression, so it is handed a plain verdict on purpose. `workspaces-react` had two hand-rolled gates that never went through `GatedControl` at all — a row-action column and the create button on a failed roster read — and both now use the same anatomy.
37
+
38
+ ## 0.19.0
39
+
40
+ ### Minor Changes
41
+
42
+ - 57bd738: Visual pass 3: delete the legacy harness stories, and stop the screens saying
43
+ the same thing twice.
44
+
45
+ **The seven harness demos are gone** (`workspace-list`, `members--default`,
46
+ `accept-invitation`, `capability-gate`, `invite-accept-flow`,
47
+ `role-select--registry`, `workspaces-provider`). They shipped a debug card —
48
+ `state.step` chips, `1 workspace(s)`, "Grant exchanged", `members.invite
49
+ allowed` — beside the real skins that replaced them, so half of what this
50
+ package showed was the harness. The headless primitives they stood for are now
51
+ covered by the skin demo that renders each one (`covers:`), and `demo/_harness`
52
+ is the provider frame and nothing else. **Breaking for anyone who deep-links a
53
+ viewer story id.**
54
+
55
+ Screen fixes, each one a repetition or a contradiction:
56
+
57
+ - **Invitations.** A terminal row printed its refusal once per control — the
58
+ same sentence six times on one phone screen. `RowActions` states it once per
59
+ row, as a footnote spanning the row, with every switched-off control's
60
+ `aria-describedby` pointing at it.
61
+ - **Workspaces page.** A failed read said so twice, in two wordings, with two
62
+ recoveries (the create button's gate carried its own copy plus `HTTP 503`
63
+ above the alert that already had the retry). One alert now, the control
64
+ points at it, and the retry sits under the alert instead of squeezing its
65
+ text into ~110px at 390px. The empty state no longer repeats the restriction
66
+ the disabled control states.
67
+ - **Workspace settings.** "Require two-factor authentication" read ON while the
68
+ line under it said two-factor was not required here — the note derived from
69
+ the absence of an enforcement status instead of from the policy. Both now
70
+ read the same value, and "no check has run yet" is its own sentence.
71
+ - **Invitation page.** A dead expired link was drawn in antd's _info_ blue with
72
+ nothing to do next; it is the warning tone, names the next step, and takes an
73
+ optional `onExit` for the host's way out.
74
+ - **Members.** The refusals on the viewer's own row widened the action column
75
+ until that row wrapped to the phone layout in the middle of a desktop table.
76
+ One row geometry per breakpoint now. The empty roster has one "Invite", not
77
+ two, and no search field over nobody.
78
+ - **Roles.** "It is not a workspace without roles." was not English. An empty
79
+ registry no longer borrows the "we could not load" sentence.
80
+ - **Membership history.** `Role: admin` was the raw slug; it goes through the
81
+ same registry labels as the picker. Events are grouped under their day, so a
82
+ burst of three no longer prints one timestamp three times.
83
+
84
+ Additive API: `InviteAcceptPageProps.onExit`, `WorkspaceFormat.time()`,
85
+ `useRoleLabel()` (a role label with no `GET /roles` behind it — the public
86
+ invitation page cannot make that call).
87
+
88
+ ## 0.18.0
89
+
90
+ ### Minor Changes
91
+
92
+ - 80617e9: The pair becomes the product: the delete button stops lying, the MFA policy gets a screen, and the invitation console exists
93
+
94
+ **The refusal a screen used to promise.** `<WorkspaceSettings/>` drew "Delete
95
+ workspace" from `my_role === "owner"` — the exact derivation stapel-workspaces
96
+ 0.26.0 added `WorkspaceResponse.can_delete` + `delete_blocked_reason` to
97
+ replace, because an owner of the instance's default workspace or of a personal
98
+ one gets a 409. The control now reads the server's verdict through
99
+ `GatedControl`, and prints the server's own refusal CODE as a sentence beside
100
+ it — translatable at last, because the generated error bundle was five codes
101
+ behind the backend (`error.409.workspace_is_instance_default`,
102
+ `workspace_is_personal`, `error.429.invitation_grant_pending`,
103
+ `error.503.billing_unavailable`, `error.503.mandate_unavailable`). The test
104
+ that cemented the old rule now asserts the new one. Name and security editing
105
+ are gated on the CAPABILITIES the server granted (`workspace.update`,
106
+ `workspace.security.manage`), falling back to the pre-0.6 owner rule only when
107
+ a backend sends no `my_capabilities` at all.
108
+
109
+ **Four screens that were hooks with no pixels.** `<WorkspacesPage/>` (the §54
110
+ default skin `WorkspaceList` never had: roster, create gated on
111
+ `can_create_workspace`, preferred-workspace choice, guest and closed-instance
112
+ copy), `<InvitationsPane/>` (list / revoke / resend / rename — four API methods
113
+ and zero pixels until now, each control gated on the invitation state the
114
+ endpoint would refuse), `<AuditTrailPane/>` (the membership history, action
115
+ vocabulary as sentences, timestamps as "3 days ago (23 Sept 2026, 09:00)"), and
116
+ `<RoleSelectField/>` (a real `<Select>` with an accessible name and the rank as
117
+ a caption, where the story used to show five bullet points).
118
+
119
+ **MFA enforcement, visible.** `mfa_enforcement` (state, coverage, unverified
120
+ members, last error) is drawn beside the `require_mfa` switch that
121
+ `useUpdateSecuritySettings` now actually has a consumer for, and
122
+ `MemberResponse.mfa_compliant` is a per-member tag with three states — true,
123
+ false, and "nobody has asked yet".
124
+
125
+ **Members reach page two.** `<Members>` takes `params` and exposes `page`
126
+ (anchor cursors + `has_next`/`has_prev`); the roster ships a pager and a
127
+ search box. `rosterComplete` now also requires `has_prev === false`, so the
128
+ last-owner claim is not made from the last page of a longer roster.
129
+
130
+ **Substrate migration.** Local `src/default/ErrorAlert.tsx` deleted; every
131
+ screen self-themes through `<SkinTheme>`, confirms are `SkinConfirm` (no
132
+ `Popconfirm`), load arms are `LoadList`/`LoadBoundary`, blocked controls are
133
+ `GatedButton`/`GatedControl`, and the unfilled `renderLoginPanel` slot renders
134
+ a `SlotPlaceholder` instead of a hole. Doctrine lint: 99 warnings → 0.
135
+
136
+ **i18n.** `es` went from 1 translated UI key to complete; `ru` stays complete
137
+ over a bundle that roughly tripled. Counts render through CLDR plurals
138
+ (`useTPlural`) instead of `1 workspace(s)`, and dates through a new
139
+ `useWorkspaceFormat()` in the model layer instead of raw ISO.
140
+
141
+ BREAKING (pre-1.0, hence minor): `MembersBag` gained `page`/`rename`/
142
+ `isRenaming`, `RoleSelect`'s `labelFor` now title-cases an unlabelled registry
143
+ role (`secretary` → `Secretary`) instead of returning the raw token, and
144
+ `src/default/ErrorAlert.tsx` is gone — import `ErrorAlert` from
145
+ `@stapel/tokens-antd/skin`.
146
+
147
+ - 308e3d6: `is_self` closes two lying controls, the four admin screens get doors, and every default-skin screen is finally photographed
148
+
149
+ **The row the roster could not identify.** stapel-workspaces 0.30.0 derives
150
+ `MemberResponse.is_self` server-side, and the pair now reads it off the
151
+ generated schema instead of a defensive cast. It gates TWO controls, not one.
152
+ "Remove" was already asking. "Reset password" was not — and it is the worse
153
+ case: `MemberPasswordResetView` refuses the caller's own row with the
154
+ byte-identical 404 it gives for a stranger ("Yourself is not in the set this
155
+ endpoint acts on"), so an ungated button reads the backend's correct refusal
156
+ as "this member has been removed". Both are switched off through `GatedButton`
157
+ with the reason beside them, in en/ru/es. A backend that sends no `is_self`
158
+ still claims nothing: the absence reads as "the server did not say".
159
+
160
+ **The administrative password reset now exists on the glass.**
161
+ `useResetMemberPassword` had no consumer at all — the endpoint (#110) was
162
+ unreachable from any shipped screen. `<MembersManager/>` grows the control and
163
+ the dialog around it, and the dialog states the three things this operation is:
164
+ the step-up is ANNOUNCED before the click (the capability is declared `high`,
165
+ so `requires_verification(scope="sensitive")` will demand one), the generated
166
+ password is labelled as the one-shot credential it is and leaves the screen
167
+ when the dialog closes (the mutation is reset), and `notified: false` is said
168
+ out loud — it means the account had no channel to be told on, which makes the
169
+ admin the only person who can tell them.
170
+
171
+ **Four finished screens stop being undeclared.** `<WorkspaceSettings/>`,
172
+ `<MembersManager/>`, `<InvitationsPane/>` and `<AuditTrailPane/>` are in the
173
+ nav manifest, on paths relative to the account section, and their `workspaceId`
174
+ prop became OPTIONAL. The architecture answer the pair was waiting for: the
175
+ active workspace is RUNTIME state — the same state the container writes when a
176
+ person switches — not a path param of a settings URL. So a nav-mounted screen
177
+ reads it from the selection seam (`useOptionalWorkspaceSelection`, new export)
178
+ and a screen with no active workspace renders a designed "choose a workspace"
179
+ (and "you are not in a workspace yet" for a person who belongs to none) rather
180
+ than a blank page or a throw from a provider a shell forgot to wire.
181
+
182
+ **The showcase stops showing the test bench.** Seven default-skin demos, one
183
+ per `/default` export, each with a phone variant and variants seeded at
184
+ distinct steps — the roster with the viewer's own row refused, the settings
185
+ screen an owner may not delete, the terminal invitations, the audit line
186
+ nobody performed, the role field with no registry to read, the invitation page
187
+ on the wrong account. `gen:demos` goes 0/7 → 7/7 skin covered. A render test
188
+ per new surface at 390 and 1024, in light and dark, plus the `is_self` and
189
+ chooser cases: 164 → 187 tests.
190
+
191
+ Peer floors raised to the substrate the pair actually imports:
192
+ `@stapel/core >=0.18.0 <1.0.0` (`useTPlural`, `STAPEL_UI_KEYS`) and
193
+ `@stapel/tokens-antd >=0.6.0` (the skin surface).
194
+
195
+ - 95e8eec: The invite dialog is a bottom sheet on a phone, and the members table stops
196
+ offering removals the backend will refuse.
197
+
198
+ `MembersManager`'s `Modal` renders through `@stapel/tokens-antd/skin`'s
199
+ `SkinDialog` — this package now declares `@stapel/tokens-antd` as an optional
200
+ peer, like every other antd-skinned pair. The table gained
201
+ `scroll={{ x: true }}`.
202
+
203
+ Remove was rendered identically on every row, including the last owner's, which
204
+ the backend enforces against. When the roster is COMPLETE (`has_next` is false
205
+ — now surfaced on the bag as `rosterComplete`) "exactly one row holds owner" is
206
+ a fact, so that row's Remove is disabled with the reason printed beside it. On a
207
+ paginated roster nothing is claimed: a count of a page is not a count of the
208
+ roster, and gating on it would refuse a removal the backend would allow.
209
+
210
+ The caller's OWN row is still not gated, and deliberately: `MemberResponse`
211
+ carries no `is_self`, and the pair has no caller identity to compare `user_id`
212
+ against (core's session exposes a status, the mandate axis a role — neither is
213
+ "who am I"). Guessing would grey out somebody else's row. The backend needs one
214
+ additive field: `is_self` on `MemberResponse`, or the caller's `user_id` on the
215
+ members page envelope.
216
+
217
+ When the role registry read fails, the per-row role `Select` no longer renders
218
+ enabled over an empty option list; the role still reads as text and the invite
219
+ dialog states the outage instead of offering a picker with nothing in it.
220
+
3
221
  ## 0.17.0
4
222
 
5
223
  ### Minor Changes
package/README.md CHANGED
@@ -51,6 +51,38 @@ per-module override — `clients={{ workspaces: runtime.client }}` — and nest
51
51
  providers (`StapelConfigProvider` + `QueryClientProvider` + `I18nProvider`)
52
52
  remain exported for bespoke composition.
53
53
 
54
+ ## The default skin (`/default`)
55
+
56
+ The pair ships the screens, not only the hooks (§83): seven antd surfaces
57
+ behind a separate entry point, so a host that brings its own visuals never
58
+ pulls `antd` into its bundle.
59
+
60
+ ```tsx
61
+ import {
62
+ WorkspacesPage, WorkspaceSettings, MembersManager,
63
+ InvitationsPane, AuditTrailPane, RoleSelectField, InviteAcceptPage,
64
+ } from "@stapel/workspaces-react/default";
65
+ ```
66
+
67
+ Each one self-themes (light/dark from the live document), is a bottom sheet on
68
+ a phone and a modal above it, and states every refusal the backend would give
69
+ instead of offering a control that leads to one.
70
+
71
+ **Which workspace a screen is about.** `WorkspaceSettings`, `MembersManager`,
72
+ `InvitationsPane` and `AuditTrailPane` take an OPTIONAL `workspaceId`. Pass it
73
+ and they use it. Omit it — which is how the nav manifest mounts them, because
74
+ the nav contract routes a screen and does not hand it an ambient scope — and
75
+ they read the ACTIVE workspace from the runtime selection
76
+ (`<WorkspaceSelectionProvider>`, the same seam `switchTo` writes). With no
77
+ active workspace they render a designed "choose a workspace" state; they never
78
+ render blank and never throw at a host that has not wired the provider.
79
+
80
+ ```tsx
81
+ <WorkspaceSelectionProvider urlWorkspaceId={params.get("workspace")}>
82
+ <MembersManager /> {/* the workspace comes from the selection */}
83
+ </WorkspaceSelectionProvider>
84
+ ```
85
+
54
86
  ## Layers
55
87
 
56
88
  ```
@@ -100,13 +132,13 @@ Backend error texts are generated from stapel-workspaces's
100
132
  pair's UI keys carry hand-written ru copy. Register your own bundle AFTER the
101
133
  pair's to override any key — registration order is override priority.
102
134
 
103
- ### Spanish locale (opt-in subpath — backend errors today, UI copy later)
135
+ ### Spanish locale (opt-in subpath)
104
136
 
105
- The `es` bundle ships as its own subpath on the same terms as `ru`, with one
106
- difference stated up front: **it translates the 67 backend error codes, not
107
- the pair's own UI copy.** `registerWorkspacesI18nEs` registers the en floor UNDER the
108
- Spanish texts, so a Spanish-speaking user reads Spanish error messages and
109
- English UI copy — never a raw key.
137
+ The `es` bundle ships as its own subpath on the same terms as `ru`, and it is
138
+ complete on both halves: the generated backend error texts AND hand-written
139
+ Spanish for every UI key the pair owns. `registerWorkspacesI18nEs` registers
140
+ the en floor UNDER the Spanish texts, so a key added tomorrow degrades to
141
+ English rather than to a raw key.
110
142
 
111
143
  ```tsx
112
144
  import { registerWorkspacesI18nEs } from "@stapel/workspaces-react/i18n/es";
@@ -119,8 +151,7 @@ await i18n.setLocale("es"); // live switch; untranslated UI keys read English
119
151
  Error texts are generated from stapel-workspaces's `translations/errors.es.json`
120
152
  catalog (`pnpm gen:errors`, drift-gated) and are complete over the error
121
153
  registry by construction. The coverage boundary is asserted in
122
- `test/i18nEs.test.ts`; when hand-written Spanish UI copy lands, it lands
123
- additively — this subpath and `workspacesI18nBundleEs` keep their names.
154
+ `test/i18nEs.test.ts`, which asserts parity with the en bundle key for key.
124
155
 
125
156
  ## Guardrails
126
157