@xemahq/ui-kernel 0.6.0 → 0.7.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 (195) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/biome-host/define-web-biome.d.ts +3 -2
  6. package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -1
  7. package/dist/lib/biome-host/define-web-biome.js +23 -1
  8. package/dist/lib/biome-host/define-web-biome.js.map +1 -1
  9. package/dist/lib/biome-host/frontend-biome.d.ts +4 -1
  10. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  11. package/dist/lib/biome-host/host-bridge.d.ts +2 -0
  12. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  13. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  14. package/dist/lib/biome-host/i18n-hooks.d.ts +4 -0
  15. package/dist/lib/biome-host/i18n-hooks.d.ts.map +1 -0
  16. package/dist/lib/biome-host/i18n-hooks.js +22 -0
  17. package/dist/lib/biome-host/i18n-hooks.js.map +1 -0
  18. package/dist/lib/biome-host/i18n-port.d.ts +15 -0
  19. package/dist/lib/biome-host/i18n-port.d.ts.map +1 -0
  20. package/dist/lib/biome-host/i18n-port.js +29 -0
  21. package/dist/lib/biome-host/i18n-port.js.map +1 -0
  22. package/dist/lib/biome-host/index.d.ts +2 -0
  23. package/dist/lib/biome-host/index.d.ts.map +1 -1
  24. package/dist/lib/biome-host/index.js +2 -0
  25. package/dist/lib/biome-host/index.js.map +1 -1
  26. package/dist/lib/biome-host/realtime-port.d.ts +1 -1
  27. package/dist/lib/biome-host/realtime-port.d.ts.map +1 -1
  28. package/dist/session-kit/approvals/approval-model.d.ts.map +1 -1
  29. package/dist/session-kit/approvals/approval-model.js.map +1 -1
  30. package/dist/ui/branding/app-branding-provider.d.ts +17 -0
  31. package/dist/ui/branding/app-branding-provider.d.ts.map +1 -0
  32. package/dist/ui/branding/app-branding-provider.js +81 -0
  33. package/dist/ui/branding/app-branding-provider.js.map +1 -0
  34. package/dist/ui/branding/sanitize.d.ts +4 -0
  35. package/dist/ui/branding/sanitize.d.ts.map +1 -0
  36. package/dist/ui/branding/sanitize.js +47 -0
  37. package/dist/ui/branding/sanitize.js.map +1 -0
  38. package/dist/ui/breakpoints.d.ts +2 -0
  39. package/dist/ui/breakpoints.d.ts.map +1 -0
  40. package/dist/ui/breakpoints.js +5 -0
  41. package/dist/ui/breakpoints.js.map +1 -0
  42. package/dist/ui/capture/document-preview.d.ts +12 -0
  43. package/dist/ui/capture/document-preview.d.ts.map +1 -0
  44. package/dist/ui/capture/document-preview.js +31 -0
  45. package/dist/ui/capture/document-preview.js.map +1 -0
  46. package/dist/ui/capture/field-spec-to-zod.d.ts +5 -0
  47. package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -0
  48. package/dist/ui/capture/field-spec-to-zod.js +71 -0
  49. package/dist/ui/capture/field-spec-to-zod.js.map +1 -0
  50. package/dist/ui/capture/field-spec.d.ts +49 -0
  51. package/dist/ui/capture/field-spec.d.ts.map +1 -0
  52. package/dist/ui/capture/field-spec.js +59 -0
  53. package/dist/ui/capture/field-spec.js.map +1 -0
  54. package/dist/ui/capture/index.d.ts +11 -0
  55. package/dist/ui/capture/index.d.ts.map +1 -0
  56. package/dist/ui/capture/index.js +31 -0
  57. package/dist/ui/capture/index.js.map +1 -0
  58. package/dist/ui/capture/schema-field-input.d.ts +20 -0
  59. package/dist/ui/capture/schema-field-input.d.ts.map +1 -0
  60. package/dist/ui/capture/schema-field-input.js +64 -0
  61. package/dist/ui/capture/schema-field-input.js.map +1 -0
  62. package/dist/ui/capture/schema-form.d.ts +18 -0
  63. package/dist/ui/capture/schema-form.d.ts.map +1 -0
  64. package/dist/ui/capture/schema-form.js +26 -0
  65. package/dist/ui/capture/schema-form.js.map +1 -0
  66. package/dist/ui/capture/use-prefill.d.ts +18 -0
  67. package/dist/ui/capture/use-prefill.d.ts.map +1 -0
  68. package/dist/ui/capture/use-prefill.js +94 -0
  69. package/dist/ui/capture/use-prefill.js.map +1 -0
  70. package/dist/ui/capture/use-schema-validation.d.ts +11 -0
  71. package/dist/ui/capture/use-schema-validation.d.ts.map +1 -0
  72. package/dist/ui/capture/use-schema-validation.js +69 -0
  73. package/dist/ui/capture/use-schema-validation.js.map +1 -0
  74. package/dist/ui/capture/use-wizard.d.ts +28 -0
  75. package/dist/ui/capture/use-wizard.d.ts.map +1 -0
  76. package/dist/ui/capture/use-wizard.js +63 -0
  77. package/dist/ui/capture/use-wizard.js.map +1 -0
  78. package/dist/ui/capture/value-format.d.ts +4 -0
  79. package/dist/ui/capture/value-format.d.ts.map +1 -0
  80. package/dist/ui/capture/value-format.js +23 -0
  81. package/dist/ui/capture/value-format.js.map +1 -0
  82. package/dist/ui/capture/wizard.d.ts +19 -0
  83. package/dist/ui/capture/wizard.d.ts.map +1 -0
  84. package/dist/ui/capture/wizard.js +34 -0
  85. package/dist/ui/capture/wizard.js.map +1 -0
  86. package/dist/ui/charts/bar-chart.d.ts +7 -0
  87. package/dist/ui/charts/bar-chart.d.ts.map +1 -0
  88. package/dist/ui/charts/bar-chart.js +47 -0
  89. package/dist/ui/charts/bar-chart.js.map +1 -0
  90. package/dist/ui/charts/chart-frame.d.ts +25 -0
  91. package/dist/ui/charts/chart-frame.d.ts.map +1 -0
  92. package/dist/ui/charts/chart-frame.js +23 -0
  93. package/dist/ui/charts/chart-frame.js.map +1 -0
  94. package/dist/ui/charts/chart-types.d.ts +19 -0
  95. package/dist/ui/charts/chart-types.d.ts.map +1 -0
  96. package/dist/ui/charts/chart-types.js +3 -0
  97. package/dist/ui/charts/chart-types.js.map +1 -0
  98. package/dist/ui/charts/chart-utils.d.ts +22 -0
  99. package/dist/ui/charts/chart-utils.d.ts.map +1 -0
  100. package/dist/ui/charts/chart-utils.js +97 -0
  101. package/dist/ui/charts/chart-utils.js.map +1 -0
  102. package/dist/ui/charts/donut-chart.d.ts +7 -0
  103. package/dist/ui/charts/donut-chart.d.ts.map +1 -0
  104. package/dist/ui/charts/donut-chart.js +55 -0
  105. package/dist/ui/charts/donut-chart.js.map +1 -0
  106. package/dist/ui/charts/index.d.ts +8 -0
  107. package/dist/ui/charts/index.d.ts.map +1 -0
  108. package/dist/ui/charts/index.js +21 -0
  109. package/dist/ui/charts/index.js.map +1 -0
  110. package/dist/ui/charts/line-chart.d.ts +7 -0
  111. package/dist/ui/charts/line-chart.d.ts.map +1 -0
  112. package/dist/ui/charts/line-chart.js +70 -0
  113. package/dist/ui/charts/line-chart.js.map +1 -0
  114. package/dist/ui/charts/sparkline.d.ts +13 -0
  115. package/dist/ui/charts/sparkline.d.ts.map +1 -0
  116. package/dist/ui/charts/sparkline.js +39 -0
  117. package/dist/ui/charts/sparkline.js.map +1 -0
  118. package/dist/ui/hooks/use-is-mobile.d.ts +2 -0
  119. package/dist/ui/hooks/use-is-mobile.d.ts.map +1 -0
  120. package/dist/ui/hooks/use-is-mobile.js +19 -0
  121. package/dist/ui/hooks/use-is-mobile.js.map +1 -0
  122. package/dist/ui/index.d.ts +7 -0
  123. package/dist/ui/index.d.ts.map +1 -1
  124. package/dist/ui/index.js +15 -1
  125. package/dist/ui/index.js.map +1 -1
  126. package/dist/ui/pdf/pdf-preview.d.ts +8 -0
  127. package/dist/ui/pdf/pdf-preview.d.ts.map +1 -0
  128. package/dist/ui/pdf/pdf-preview.js +34 -0
  129. package/dist/ui/pdf/pdf-preview.js.map +1 -0
  130. package/dist/ui/primitives/badge.d.ts +1 -1
  131. package/dist/ui/primitives/button.d.ts +1 -1
  132. package/dist/ui/primitives/preview-device-frame.d.ts +9 -0
  133. package/dist/ui/primitives/preview-device-frame.d.ts.map +1 -0
  134. package/dist/ui/primitives/preview-device-frame.js +90 -0
  135. package/dist/ui/primitives/preview-device-frame.js.map +1 -0
  136. package/dist/ui/primitives/sheet.d.ts +1 -1
  137. package/package.json +10 -8
  138. package/src/index.ts +5 -0
  139. package/src/lib/biome-host/define-web-biome.test.js +93 -0
  140. package/src/lib/biome-host/define-web-biome.ts +71 -4
  141. package/src/lib/biome-host/frontend-biome.ts +45 -4
  142. package/src/lib/biome-host/host-bridge.ts +13 -0
  143. package/src/lib/biome-host/i18n-hooks.ts +50 -0
  144. package/src/lib/biome-host/i18n-port.test.ts +50 -0
  145. package/src/lib/biome-host/i18n-port.ts +96 -0
  146. package/src/lib/biome-host/index.ts +2 -0
  147. package/src/lib/biome-host/realtime-port.ts +9 -3
  148. package/src/lib/system-bus/enums.ts +1 -1
  149. package/src/lib/system-bus/index.ts +1 -1
  150. package/src/lib/system-bus/intents.ts +1 -1
  151. package/src/session-kit/approvals/ApprovalCard.tsx +1 -1
  152. package/src/session-kit/approvals/ApprovalsCenter.tsx +2 -2
  153. package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +1 -1
  154. package/src/session-kit/approvals/Obligation.tsx +1 -1
  155. package/src/session-kit/approvals/ScopedBody.tsx +1 -1
  156. package/src/session-kit/approvals/approval-model.ts +7 -8
  157. package/src/session-kit/approvals/index.ts +1 -1
  158. package/src/session-kit/approvals/obligation-display.ts +1 -1
  159. package/src/session-kit/approvals/risk-accent.ts +1 -1
  160. package/src/session-kit/combobox/Combobox.tsx +1 -1
  161. package/src/session-kit/display/MessageTurn.tsx +1 -1
  162. package/src/session-kit/display/TokenMeter.tsx +1 -1
  163. package/src/session-kit/index.ts +3 -3
  164. package/src/session-kit/lib/enums.ts +1 -1
  165. package/src/session-kit/lib/status-dot.ts +1 -1
  166. package/src/session-kit/primitives/RiskPill.tsx +1 -1
  167. package/src/ui/branding/app-branding-provider.tsx +146 -0
  168. package/src/ui/branding/sanitize.ts +77 -0
  169. package/src/ui/breakpoints.ts +14 -0
  170. package/src/ui/capture/document-preview.tsx +101 -0
  171. package/src/ui/capture/field-spec-to-zod.test.ts +59 -0
  172. package/src/ui/capture/field-spec-to-zod.ts +93 -0
  173. package/src/ui/capture/field-spec.test.ts +49 -0
  174. package/src/ui/capture/field-spec.ts +160 -0
  175. package/src/ui/capture/index.ts +43 -0
  176. package/src/ui/capture/schema-field-input.tsx +186 -0
  177. package/src/ui/capture/schema-form.tsx +108 -0
  178. package/src/ui/capture/use-prefill.ts +150 -0
  179. package/src/ui/capture/use-schema-validation.ts +113 -0
  180. package/src/ui/capture/use-wizard.ts +134 -0
  181. package/src/ui/capture/value-format.ts +22 -0
  182. package/src/ui/capture/wizard.tsx +100 -0
  183. package/src/ui/charts/bar-chart.tsx +135 -0
  184. package/src/ui/charts/chart-frame.tsx +112 -0
  185. package/src/ui/charts/chart-types.ts +52 -0
  186. package/src/ui/charts/chart-utils.ts +158 -0
  187. package/src/ui/charts/charts.test.tsx +204 -0
  188. package/src/ui/charts/donut-chart.tsx +111 -0
  189. package/src/ui/charts/index.ts +27 -0
  190. package/src/ui/charts/line-chart.tsx +180 -0
  191. package/src/ui/charts/sparkline.tsx +101 -0
  192. package/src/ui/hooks/use-is-mobile.ts +28 -0
  193. package/src/ui/index.ts +25 -0
  194. package/src/ui/pdf/pdf-preview.tsx +122 -0
  195. package/src/ui/primitives/preview-device-frame.tsx +205 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/ui-kernel",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Host-framework-agnostic UI kernel for the Xema OS. Defines the SystemBus orchestration contract (capability.invoke, cross-biome intents, command palette, xema:// deeplinks, window manager) AND the biome-host contract surface (FrontendBiome/FrontendBiomeFactory, HostBridge, the singleton biomeRegistry, session contributions) that every frontend biome composes against. No Vite, Next.js, or React-Router — React itself IS allowed as the shared component model (the contracts traffic in ReactNode/ComponentType and a React context). Concrete host adapters (router/auth/toast wiring) live in separate packages that consume this kernel. The SystemBus is pure orchestration: it never authorizes, the backend capability-router enforces all policy.",
5
5
  "keywords": [
6
6
  "xema",
@@ -12,16 +12,15 @@
12
12
  "deeplink",
13
13
  "intents"
14
14
  ],
15
+ "license": "Apache-2.0",
16
+ "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
15
17
  "homepage": "https://docs.xema.dev/biomes",
16
- "bugs": {
17
- "url": "https://github.com/xema-dev/xema-monorepo/issues"
18
- },
19
18
  "repository": {
20
19
  "type": "git",
21
- "url": "git+https://github.com/xema-dev/xema-monorepo.git",
20
+ "url": "git+https://github.com/xema-dev/xema-kernel-sdk.git",
22
21
  "directory": "packages/ui/xema-ui-kernel"
23
22
  },
24
- "license": "Apache-2.0",
23
+ "bugs": "https://github.com/xema-dev/xema-kernel-sdk/issues",
25
24
  "publishConfig": {
26
25
  "access": "public",
27
26
  "registry": "https://registry.npmjs.org/"
@@ -54,8 +53,10 @@
54
53
  "class-variance-authority": "^0.7.1",
55
54
  "clsx": "^2.1.1",
56
55
  "lucide-react": "^0.462.0",
56
+ "react-pdf": "^9.1.1",
57
57
  "react-resizable-panels": "^4.9.0",
58
- "tailwind-merge": "^2.5.4"
58
+ "tailwind-merge": "^2.5.4",
59
+ "zod": "^4.4.3"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "@tanstack/react-query": "^5.0.0",
@@ -74,7 +75,7 @@
74
75
  "prettier": "3.6.2",
75
76
  "react": "^18.3.1",
76
77
  "typescript": "5.9.3",
77
- "@xemahq/kernel-contracts": "^0.14.0"
78
+ "@xemahq/kernel-contracts": "^0.39.0"
78
79
  },
79
80
  "exports": {
80
81
  ".": {
@@ -101,6 +102,7 @@
101
102
  "scripts": {
102
103
  "clean": "rm -rf dist",
103
104
  "build": "tsc -p tsconfig.json",
105
+ "test": "echo '[ui-kernel] *.{test,spec} files run under host-web vitest (this package carries no vitest dep by design — see tsconfig exclude). Write tests with vitest; the frontend-bridge sync fails on a node:test file.'",
104
106
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
105
107
  "lint": "tsc -p tsconfig.lint.json --noEmit",
106
108
  "typecheck": "tsc -p tsconfig.lint.json --noEmit"
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  export * from './lib/system-bus';
2
2
  export * from './lib/biome-host';
3
3
  export * from './lib/capabilities';
4
+
5
+ // Guided-capture UX kit (B5). Also available at the `@xemahq/ui-kernel/ui`
6
+ // subpath alongside the rest of the shared UI surface; re-exported here so the
7
+ // capture primitives are reachable from the package root too.
8
+ export * from './ui/capture';
@@ -0,0 +1,93 @@
1
+ // Test suite for defineWebBiome's define-time scope/access validation and the
2
+ // chrome default. Runs on Node's native test runner against the COMPILED dist
3
+ // (CommonJS), so the package needs no jest/vitest/ts-jest toolchain — the
4
+ // `test` script builds first, then runs this file with `node --test`.
5
+ //
6
+ // Covered:
7
+ // 1. A `public` page with access `'member'` (or omitted) throws at define time.
8
+ // 2. A `public` + access `'public'` page is accepted; its route carries no
9
+ // nav item and mounts with access `'public'`.
10
+ // 3. A non-public page declaring access `'public'` throws at define time.
11
+ // 4. `chrome` defaults to `'host'` when a page omits it; `'none'` passes through.
12
+
13
+ 'use strict';
14
+
15
+ const test = require('node:test');
16
+ const assert = require('node:assert/strict');
17
+
18
+ const { defineWebBiome } = require('../../../dist/lib/biome-host/define-web-biome.js');
19
+
20
+ const load = () => Promise.resolve({ default: () => null });
21
+ const bridge = /** @type {any} */ ({});
22
+
23
+ test('rejects a public page whose access is not public (member default)', () => {
24
+ assert.throws(
25
+ () =>
26
+ defineWebBiome({
27
+ id: 'test-biome',
28
+ displayName: 'Test',
29
+ pages: [{ slug: 'ext/:id', label: 'Ext', scope: 'public', load }],
30
+ }),
31
+ /a 'public' scope page MUST declare access: 'public'/,
32
+ );
33
+ });
34
+
35
+ test('rejects a public page with an explicit non-public access tier', () => {
36
+ assert.throws(
37
+ () =>
38
+ defineWebBiome({
39
+ id: 'test-biome',
40
+ displayName: 'Test',
41
+ pages: [
42
+ { slug: 'ext/:id', label: 'Ext', scope: 'public', access: 'org-admin', load },
43
+ ],
44
+ }),
45
+ /a 'public' scope page MUST declare access: 'public'/,
46
+ );
47
+ });
48
+
49
+ test('rejects a non-public page that declares access public', () => {
50
+ assert.throws(
51
+ () =>
52
+ defineWebBiome({
53
+ id: 'test-biome',
54
+ displayName: 'Test',
55
+ pages: [{ slug: 'system/x', label: 'X', access: 'public', load }],
56
+ }),
57
+ /access: 'public' is only valid on a 'public' scope page/,
58
+ );
59
+ });
60
+
61
+ test('accepts a public + access:public page and emits no nav item', () => {
62
+ const factory = defineWebBiome({
63
+ id: 'test-biome',
64
+ displayName: 'Test',
65
+ pages: [
66
+ { slug: 'ext/:id', label: 'Ext', scope: 'public', access: 'public', load },
67
+ ],
68
+ });
69
+ const biome = factory(bridge);
70
+ assert.equal(biome.routes.length, 1);
71
+ const route = biome.routes[0];
72
+ assert.equal(route.path, 'ext/:id');
73
+ assert.equal(route.access, 'public');
74
+ assert.equal(route.projectScoped, false);
75
+ // Public routes never enter the authenticated app rail.
76
+ assert.equal(biome.navItems.length, 0);
77
+ });
78
+
79
+ test('chrome defaults to host and none passes through', () => {
80
+ const factory = defineWebBiome({
81
+ id: 'test-biome',
82
+ displayName: 'Test',
83
+ pages: [
84
+ { slug: 'system/a', label: 'A', load },
85
+ { slug: 'system/b', label: 'B', chrome: 'none', load },
86
+ ],
87
+ });
88
+ const biome = factory(bridge);
89
+ const a = biome.routes.find((r) => r.path === 'system/a');
90
+ const b = biome.routes.find((r) => r.path === 'system/b');
91
+ assert.equal(a.chrome, 'host');
92
+ assert.equal(b.chrome, 'none');
93
+ });
@@ -44,8 +44,10 @@ import type {
44
44
  FrontendBiome,
45
45
  FrontendBiomeFactory,
46
46
  NavItemContribution,
47
+ PageChrome,
47
48
  RouteAccess,
48
49
  RouteContribution,
50
+ RouteScope,
49
51
  } from './frontend-biome';
50
52
  import type { ComponentType } from 'react';
51
53
 
@@ -75,11 +77,24 @@ export interface WebBiomePage {
75
77
  readonly category?: string;
76
78
  /**
77
79
  * Route scope. `'org'` (default) mounts at the org root; `'project'` mounts
78
- * under `/projects/:projectId`. Maps to {@link RouteContribution.projectScoped}.
80
+ * under the canonical project-scoped route prefix; `'public'` mounts OUTSIDE
81
+ * the host's authenticated group (no Keycloak session, no login redirect, no
82
+ * org switcher). Maps to {@link RouteContribution.projectScoped} for the
83
+ * authenticated scopes; a `'public'` page MUST also declare
84
+ * `access: 'public'` (enforced at define time).
85
+ */
86
+ readonly scope?: RouteScope;
87
+ /**
88
+ * Access tier required to render the route. Defaults to `'member'`. A
89
+ * `'public'` page (scope `'public'`) MUST set this to `'public'`; a non-public
90
+ * page MUST NOT — both contradictions are rejected at define time.
79
91
  */
80
- readonly scope?: 'org' | 'project';
81
- /** Access tier required to render the route. Defaults to `'member'`. */
82
92
  readonly access?: RouteAccess;
93
+ /**
94
+ * Whether the host wraps the page in its application shell. Defaults to
95
+ * `'host'`; `'none'` renders with no app rail, topbar, or org switcher.
96
+ */
97
+ readonly chrome?: PageChrome;
83
98
  /** Dynamic import of the page module (`() => import('./pages/X')`). */
84
99
  readonly load: () => Promise<{ default: ComponentType<unknown> }>;
85
100
  /**
@@ -108,6 +123,47 @@ export interface DefineWebBiomeOptions {
108
123
  readonly outputRenderers?: FrontendBiome['outputRenderers'];
109
124
  }
110
125
 
126
+ /** Default host-shell mode a page renders in when it omits `chrome`. */
127
+ const DEFAULT_PAGE_CHROME: PageChrome = 'host';
128
+
129
+ /**
130
+ * Reject the two `scope`/`access` contradictions at define time (module load,
131
+ * when the biome calls `defineWebBiome(...)` at its top level) — fail fast, do
132
+ * NOT silently coerce:
133
+ *
134
+ * 1. A `public` page with any `access` other than `'public'`: a public route
135
+ * is un-authenticated, so a privilege tier is meaningless. The author must
136
+ * set `access: 'public'` explicitly (an omitted `access` defaults to
137
+ * `'member'`, which is exactly this contradiction).
138
+ * 2. A non-`public` page declaring `access: 'public'`: `'public'` is the
139
+ * no-auth marker; an org/project route is always authenticated.
140
+ *
141
+ * `scope` defaults to `'org'` and `access` defaults to `'member'` for the
142
+ * purpose of this check, matching the emit defaults below.
143
+ */
144
+ function assertPageScopeAccessLocked(biomeId: string, page: WebBiomePage): void {
145
+ const scope: RouteScope = page.scope ?? 'org';
146
+ const access: RouteAccess = page.access ?? 'member';
147
+ const where = `web biome '${biomeId}' page '${page.slug}'`;
148
+
149
+ if (scope === 'public' && access !== 'public') {
150
+ throw new Error(
151
+ `${where}: a 'public' scope page MUST declare access: 'public' ` +
152
+ `(got access: '${access}'). A public route is un-authenticated, so an ` +
153
+ `authenticated privilege tier is a contradiction. Set access: 'public' ` +
154
+ `or change the scope.`,
155
+ );
156
+ }
157
+ if (scope !== 'public' && access === 'public') {
158
+ throw new Error(
159
+ `${where}: access: 'public' is only valid on a 'public' scope page ` +
160
+ `(got scope: '${scope}'). '${access}' is the no-auth marker; an ` +
161
+ `org/project route is always authenticated. Set scope: 'public' or ` +
162
+ `change the access tier.`,
163
+ );
164
+ }
165
+ }
166
+
111
167
  /**
112
168
  * Build the `FrontendBiomeFactory` a web biome default-exports. See the
113
169
  * module doc-comment for the rationale and an example.
@@ -115,6 +171,13 @@ export interface DefineWebBiomeOptions {
115
171
  export function defineWebBiome(
116
172
  options: DefineWebBiomeOptions,
117
173
  ): FrontendBiomeFactory {
174
+ // Validate every page's scope/access pairing NOW, at module load, so a
175
+ // contradiction surfaces the instant the biome module is imported — not
176
+ // lazily when the host first calls the factory.
177
+ for (const page of options.pages) {
178
+ assertPageScopeAccessLocked(options.id, page);
179
+ }
180
+
118
181
  return () => {
119
182
  const navItems: NavItemContribution[] = [];
120
183
  const routes: RouteContribution[] = [];
@@ -124,10 +187,14 @@ export function defineWebBiome(
124
187
  path: page.slug,
125
188
  projectScoped: page.scope === 'project',
126
189
  element: lazyRoute(page.load),
190
+ chrome: page.chrome ?? DEFAULT_PAGE_CHROME,
127
191
  ...(page.access ? { access: page.access } : {}),
128
192
  });
129
193
 
130
- if (!page.navHidden) {
194
+ // Public routes mount OUTSIDE the authenticated group and never belong in
195
+ // the authenticated app rail — so they contribute no nav item regardless
196
+ // of `navHidden`. Authenticated pages emit a nav item unless hidden.
197
+ if (!page.navHidden && page.scope !== 'public') {
131
198
  navItems.push({
132
199
  id: page.id ?? page.slug,
133
200
  label: page.label,
@@ -44,8 +44,42 @@ export interface NavItemContribution {
44
44
  * declaratively — nav-hide is NOT a security boundary, a deeplink bypasses it.
45
45
  * Precedence (least → most privileged): `member` < `org-admin` <
46
46
  * `platform-admin`. Defaults to `member` when a route omits the field.
47
+ *
48
+ * `public` is NOT a privilege tier — it is the *no-auth* marker: a `public`
49
+ * route requires no Keycloak session at all and mounts OUTSIDE the host's
50
+ * authenticated route group. It is value-locked to `RouteScope` `'public'`
51
+ * (`defineWebBiome` rejects any other pairing at define time), so a route is
52
+ * public in BOTH senses or in neither. Closed set — a new member is a
53
+ * one-line change here and one `case` in the host resolver's exhaustive
54
+ * `hasRouteAccess` switch.
55
+ */
56
+ export type RouteAccess = 'member' | 'org-admin' | 'platform-admin' | 'public';
57
+
58
+ /**
59
+ * Where a route mounts in the host's URL/auth topology. Closed set — adding a
60
+ * member is a one-line change here (and the single mapping in `defineWebBiome`
61
+ * plus one resolver partition in the host).
62
+ *
63
+ * - `org` — mounts at the org root, inside the authenticated group.
64
+ * - `project` — mounts under the project-scoped route prefix, inside the
65
+ * authenticated group.
66
+ * - `public` — mounts OUTSIDE the authenticated group; no Keycloak session,
67
+ * no login redirect, no org switcher. Locked to `RouteAccess` `'public'`.
47
68
  */
48
- export type RouteAccess = 'member' | 'org-admin' | 'platform-admin';
69
+ export type RouteScope = 'org' | 'project' | 'public';
70
+
71
+ /**
72
+ * Whether a page renders wrapped in the host application shell (app rail,
73
+ * topbar, org switcher) or standalone. Closed set — a new member is a one-line
74
+ * change here. Defaults to `'host'` when a page omits it.
75
+ *
76
+ * - `host` — the default; the host renders its chrome around the page.
77
+ * - `none` — the host renders the page with NO app rail, topbar, or org
78
+ * switcher (a full-bleed surface). `public` routes are structurally
79
+ * chromeless (their route group ships no shell); `none` also lets an
80
+ * authenticated route opt out of the shell.
81
+ */
82
+ export type PageChrome = 'host' | 'none';
49
83
 
50
84
  export interface RouteContribution {
51
85
  /** React-router path. May include params. */
@@ -63,9 +97,16 @@ export interface RouteContribution {
63
97
  readonly projectScoped: boolean;
64
98
  /**
65
99
  * Access tier required to render this route. The host resolver enforces it
66
- * declaratively. Defaults to `'member'` when omitted.
100
+ * declaratively. Defaults to `'member'` when omitted. `'public'` marks a
101
+ * no-auth route the host mounts outside its authenticated group.
67
102
  */
68
103
  readonly access?: RouteAccess;
104
+ /**
105
+ * Whether the host wraps this route in its application shell. Defaults to
106
+ * `'host'` when omitted; `'none'` renders the page with no app rail,
107
+ * topbar, or org switcher. See {@link PageChrome}.
108
+ */
109
+ readonly chrome?: PageChrome;
69
110
  }
70
111
 
71
112
  /**
@@ -182,7 +223,7 @@ export interface OutputRendererContribution {
182
223
  *
183
224
  * This factory shape keeps biome code free of any direct dependency
184
225
  * on host-shell primitives (router, auth client, toast library), which
185
- * is what allows the same biome package to load into a Vite SPA host
186
- * today and a Next.js host tomorrow.
226
+ * is what allows the same biome package to load into one host runtime
227
+ * today and a different host runtime tomorrow.
187
228
  */
188
229
  export type FrontendBiomeFactory = (bridge: HostBridge) => FrontendBiome;
@@ -5,6 +5,7 @@ import { createContext, useContext, type ReactNode } from 'react';
5
5
  import type { CapabilityPort } from '../capabilities';
6
6
  import type { SystemBus } from '../system-bus';
7
7
 
8
+ import type { I18nSource } from './i18n-port';
8
9
  import type { RealtimeSource } from './realtime-port';
9
10
 
10
11
  /**
@@ -235,6 +236,18 @@ export interface HostBridge {
235
236
  * actionable error if a biome uses them before the host wires `bridge.realtime`.
236
237
  */
237
238
  readonly realtime?: RealtimeSource;
239
+ /**
240
+ * Localization port. The HOST injects a concrete {@link I18nSource} backed by
241
+ * its locale provider (authoritative locale from org/app/user settings +
242
+ * catalog loading); biomes reach it through the kernel `useT` / `useLocale`
243
+ * hooks so biome code never imports an i18n library directly — same decoupling
244
+ * as `navigation` / `realtime`.
245
+ *
246
+ * OPTIONAL: a host without localization omits it; the kernel `useT`/`useLocale`
247
+ * hooks fail fast with an actionable error if a biome uses them before the host
248
+ * wires `bridge.i18n`.
249
+ */
250
+ readonly i18n?: I18nSource;
238
251
  /**
239
252
  * Error-decoding surface — the canonical user-facing error decoder. Defaults
240
253
  * (in the kernel default bridge wiring) to {@link getUserFacingErrorMessage};
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Kernel-side i18n hooks. Biomes call these INSTEAD of importing an i18n library
3
+ * or the host's locale provider directly, so biome code stays decoupled from the
4
+ * i18n implementation — the same decoupling `useHostBridge` provides for
5
+ * navigation / auth / realtime / capabilities.
6
+ *
7
+ * Each hook reads the host-injected {@link I18nSource} off `bridge.i18n` and
8
+ * delegates straight through. The host owns the authoritative locale + catalog
9
+ * loading; the kernel owns only the contract + this thin delegation.
10
+ */
11
+
12
+ import { useHostBridge } from './host-bridge';
13
+ import type { I18nSource, Translator } from './i18n-port';
14
+
15
+ /**
16
+ * Resolve the host-injected i18n source, failing fast with an actionable message
17
+ * if the host has not wired `bridge.i18n`. `bridge.i18n` is an optional port (a
18
+ * host without localization omits it), so the kernel hooks must not silently
19
+ * no-op — a biome that calls them expects translated copy.
20
+ */
21
+ function requireI18n(i18n: I18nSource | undefined): I18nSource {
22
+ if (!i18n) {
23
+ throw new Error(
24
+ 'useT/useLocale require an i18n source, but the host has not wired ' +
25
+ '`bridge.i18n`. The host must provide an I18nSource (locale + catalogs) ' +
26
+ 'on the HostBridge to use kernel i18n hooks.',
27
+ );
28
+ }
29
+ return i18n;
30
+ }
31
+
32
+ /**
33
+ * A {@link Translator} for the active locale, scoped to an optional namespace.
34
+ * Call at render; the returned `t(key, params?)` interpolates `{name}` tokens.
35
+ *
36
+ * @example
37
+ * const t = useT('radar');
38
+ * return <h1>{t('title')}</h1>; // radar.title
39
+ * return <p>{t('greeting', { name })}</p>; // "Hello, {name}"
40
+ */
41
+ export function useT(namespace?: string): Translator {
42
+ const bridge = useHostBridge();
43
+ return requireI18n(bridge.i18n).useTranslator(namespace);
44
+ }
45
+
46
+ /** The active BCP-47 locale tag (e.g. `"pt-PT"`) for date/number formatting. */
47
+ export function useLocale(): string {
48
+ const bridge = useHostBridge();
49
+ return requireI18n(bridge.i18n).useLocale();
50
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { makeTranslator } from './i18n-port';
4
+
5
+ const en = {
6
+ title: 'Dashboard',
7
+ greeting: 'Hello, {name}',
8
+ radar: { title: 'Radar', empty: 'No leads yet' },
9
+ } as const;
10
+
11
+ describe('makeTranslator', () => {
12
+ it('resolves flat and nested dot-path keys', () => {
13
+ const t = makeTranslator(en);
14
+ expect(t('title')).toBe('Dashboard');
15
+ expect(t('radar.title')).toBe('Radar');
16
+ });
17
+
18
+ it('interpolates {name} placeholders', () => {
19
+ const t = makeTranslator(en);
20
+ expect(t('greeting', { name: 'Ada' })).toBe('Hello, Ada');
21
+ });
22
+
23
+ it('leaves an unknown placeholder verbatim (visible authoring bug)', () => {
24
+ const t = makeTranslator({ msg: 'Hi {who}' });
25
+ expect(t('msg', {})).toBe('Hi {who}');
26
+ });
27
+
28
+ it('applies a namespace prefix', () => {
29
+ const t = makeTranslator(en, { namespace: 'radar' });
30
+ expect(t('title')).toBe('Radar');
31
+ expect(t('empty')).toBe('No leads yet');
32
+ });
33
+
34
+ it('returns the raw key for a missing message (never blank, never throws)', () => {
35
+ const t = makeTranslator(en);
36
+ expect(t('does.not.exist')).toBe('does.not.exist');
37
+ });
38
+
39
+ it('falls back message-by-message to the default-locale catalog', () => {
40
+ const pt = { title: 'Painel' }; // partial locale — no greeting
41
+ const t = makeTranslator(pt, { fallback: en });
42
+ expect(t('title')).toBe('Painel'); // primary wins
43
+ expect(t('greeting', { name: 'Ada' })).toBe('Hello, Ada'); // fallback fills the gap
44
+ });
45
+
46
+ it('coerces numeric params to strings', () => {
47
+ const t = makeTranslator({ count: '{n} items' });
48
+ expect(t('count', { n: 3 })).toBe('3 items');
49
+ });
50
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Kernel i18n contract + a framework-free translator core.
3
+ *
4
+ * Xema's locale is DATA-driven (per-org / per-app / per-user settings), not
5
+ * URL-driven, so the kernel deliberately does NOT depend on next-intl / react-
6
+ * intl. A biome localises its UI through the host-injected {@link I18nSource}
7
+ * (reached via the `useT` / `useLocale` kernel hooks) exactly the way it reaches
8
+ * realtime / navigation — the biome never imports an i18n library, and the host
9
+ * owns catalog loading + the authoritative locale source.
10
+ *
11
+ * The {@link makeTranslator} core here is pure and host-agnostic: a host (or a
12
+ * biome, or a test) builds a `Translator` from a nested-JSON catalog with no
13
+ * framework in the loop.
14
+ */
15
+
16
+ /** Interpolation values for a message's `{name}` placeholders. */
17
+ export type TranslateParams = Readonly<Record<string, string | number>>;
18
+
19
+ /**
20
+ * Resolve one message key to a localized string, interpolating `{name}`
21
+ * placeholders. Missing keys return the key itself (visible, never a throw or a
22
+ * blank) — the standard i18n contract, not a silent business-logic fallback.
23
+ */
24
+ export type Translator = (key: string, params?: TranslateParams) => string;
25
+
26
+ /** A nested message catalog: dot-path keys map to strings (or sub-catalogs). */
27
+ export interface Catalog {
28
+ readonly [segment: string]: string | Catalog;
29
+ }
30
+
31
+ export interface MakeTranslatorOptions {
32
+ /** Prefix every lookup with this namespace path (e.g. `"radar"`). */
33
+ readonly namespace?: string;
34
+ /**
35
+ * Default-locale catalog consulted per-key when the primary catalog lacks a
36
+ * message — so a partially-translated locale falls back message-by-message
37
+ * instead of dropping to the raw key.
38
+ */
39
+ readonly fallback?: Catalog;
40
+ }
41
+
42
+ function lookup(catalog: Catalog, path: readonly string[]): string | undefined {
43
+ let node: string | Catalog | undefined = catalog;
44
+ for (const segment of path) {
45
+ if (node === undefined || typeof node === 'string') {
46
+ return undefined;
47
+ }
48
+ node = node[segment];
49
+ }
50
+ return typeof node === 'string' ? node : undefined;
51
+ }
52
+
53
+ function interpolate(message: string, params?: TranslateParams): string {
54
+ if (!params) {
55
+ return message;
56
+ }
57
+ // Replace `{name}` tokens; an unknown token is left verbatim (visible), never
58
+ // blanked — a missing param is an authoring bug that should be obvious.
59
+ return message.replaceAll(/\{(\w+)\}/g, (whole, name: string) =>
60
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Build a {@link Translator} over a catalog. Pure — no React, no framework, no
66
+ * global state. Resolution order per key: primary catalog → fallback catalog →
67
+ * the key itself.
68
+ */
69
+ export function makeTranslator(
70
+ catalog: Catalog,
71
+ options?: MakeTranslatorOptions,
72
+ ): Translator {
73
+ const prefix = options?.namespace ? options.namespace.split('.') : [];
74
+ const fallback = options?.fallback;
75
+ return (key, params) => {
76
+ const path = [...prefix, ...key.split('.')];
77
+ const message =
78
+ lookup(catalog, path) ?? (fallback ? lookup(fallback, path) : undefined) ?? key;
79
+ return interpolate(message, params);
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Host-implemented i18n source. The host wires these against its locale provider
85
+ * (authoritative locale from org/app/user settings + catalog loading); biomes
86
+ * reach them through the kernel {@link useT} / {@link useLocale} hooks.
87
+ *
88
+ * Every method is a React hook (reads locale/catalog context), so it MUST be
89
+ * called from a component's render.
90
+ */
91
+ export interface I18nSource {
92
+ /** A {@link Translator} for the active locale, optionally namespaced. */
93
+ useTranslator(namespace?: string): Translator;
94
+ /** The active BCP-47 locale tag (e.g. `"pt-PT"`). */
95
+ useLocale(): string;
96
+ }
@@ -28,6 +28,8 @@ export * from './biome-mode';
28
28
  export * from './agent-validation';
29
29
  export * from './realtime-port';
30
30
  export * from './realtime-hooks';
31
+ export * from './i18n-port';
32
+ export * from './i18n-hooks';
31
33
  export * from './errors';
32
34
  export * from './response-envelope';
33
35
  export * from './use-mutation-with-error-toast';
@@ -73,11 +73,17 @@ export interface RealtimeConnectionState {
73
73
 
74
74
  /**
75
75
  * A server-side scope a connection can subscribe to so it only receives the
76
- * events for one project or session, rather than the full org firehose. The
77
- * host's transport reference-counts identical scopes across mounts.
76
+ * events for one project, session, or app, rather than the full org firehose.
77
+ * The host's transport reference-counts identical scopes across mounts.
78
+ *
79
+ * `app` is the app-surface scope: an external/app-facing biome subscribes to
80
+ * its app's event stream (delivery is authz-gated server-side by the subject's
81
+ * app membership). It is value-identical to the server `ScopeKind.app` and the
82
+ * host transport's `EventScopeOptions` — the kernel union is the last hop to
83
+ * carry it, so a decoupled/remote biome can type an app subscription.
78
84
  */
79
85
  export interface RealtimeScope {
80
- readonly kind: 'project' | 'session';
86
+ readonly kind: 'project' | 'session' | 'app';
81
87
  readonly id: string;
82
88
  }
83
89
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Closed sets for the `SystemBus` contract (Phase 6b).
2
+ * Closed sets for the `SystemBus` contract.
3
3
  *
4
4
  * Every closed domain the SystemBus exposes is a real TypeScript enum so
5
5
  * the contract is stable across hosts and serialises predictably. The
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * SystemBus — the framework-agnostic OS-orchestration contract plane
3
- * (Phase 6b). Pure types + a small framework-agnostic runtime
3
+ * Pure types + a small framework-agnostic runtime
4
4
  * ({@link IntentRegistry} + {@link parseDeeplink}). No React, no Vite, no
5
5
  * Next.js, no React-Router, no transport client.
6
6
  */
@@ -10,7 +10,7 @@ import { ActionRefKind } from './enums';
10
10
  * `intents.resolve(kind)` returns every action any biome registered for
11
11
  * that kind. This is how one biome hands an object to another WITHOUT
12
12
  * importing it — the macOS "Open With" / Share-Sheet analogy from the
13
- * plan-of-record (§12.3).
13
+ * platform design.
14
14
  *
15
15
  * The registry is pure orchestration: it indexes contributions and
16
16
  * resolves them. It performs NO authorization — whether the actor may
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The GENERIC approval frame (HANDOFF §6) — identical platform-wide.
2
+ * The GENERIC approval frame — identical platform-wide.
3
3
  *
4
4
  * Header: "Approval required" + RiskPill (reused from the kit) + mono
5
5
  * capability ref + serif title + summary. Then the biome-supplied
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Approvals Center body (HANDOFF §6) — the cross-portal batch-review list.
2
+ * Approvals Center body — the cross-portal batch-review list.
3
3
  *
4
4
  * This is the GENERIC, prop-driven body (toolbar + rows + empty state). It
5
5
  * does NOT own the drawer chrome: the host wraps it in its own `Sheet`
@@ -11,7 +11,7 @@
11
11
  * portal + biome + quorum and an "Open session" affordance. "Select
12
12
  * low-risk" selects every low/medium item; bulk "Approve N" resolves the
13
13
  * selection. The row model (`ApprovalCenterItem`) already carries
14
- * portalId/name/accent so Pillar 4 can group by portal without a reshape.
14
+ * portalId/name/accent so host portals can group by portal without a reshape.
15
15
  */
16
16
  import { CapabilityRiskTier } from '@xemahq/kernel-contracts/capability';
17
17
  import { useCallback, useState, type ReactElement } from 'react';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Sticky CAPABILITY-approval bar (HANDOFF §6) — pinned above the composer
2
+ * Sticky CAPABILITY-approval bar — pinned above the composer
3
3
  * so a pending capability approval is always actionable without scrolling
4
4
  * back to the inline card.
5
5
  *