@xemahq/ui-kernel 0.1.12 → 0.2.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/host-bridge.d.ts +2 -0
  6. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  7. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  8. package/dist/lib/capabilities/capability-provider.d.ts +15 -0
  9. package/dist/lib/capabilities/capability-provider.d.ts.map +1 -0
  10. package/dist/lib/capabilities/capability-provider.js +36 -0
  11. package/dist/lib/capabilities/capability-provider.js.map +1 -0
  12. package/dist/lib/capabilities/index.d.ts +4 -0
  13. package/dist/lib/capabilities/index.d.ts.map +1 -0
  14. package/dist/lib/capabilities/index.js +20 -0
  15. package/dist/lib/capabilities/index.js.map +1 -0
  16. package/dist/lib/capabilities/types.d.ts +18 -0
  17. package/dist/lib/capabilities/types.d.ts.map +1 -0
  18. package/dist/lib/capabilities/types.js +3 -0
  19. package/dist/lib/capabilities/types.js.map +1 -0
  20. package/dist/lib/capabilities/use-capability.d.ts +18 -0
  21. package/dist/lib/capabilities/use-capability.d.ts.map +1 -0
  22. package/dist/lib/capabilities/use-capability.js +21 -0
  23. package/dist/lib/capabilities/use-capability.js.map +1 -0
  24. package/dist/ui/chrome/AsyncBoundary.d.ts +22 -0
  25. package/dist/ui/chrome/AsyncBoundary.d.ts.map +1 -0
  26. package/dist/ui/chrome/AsyncBoundary.js +23 -0
  27. package/dist/ui/chrome/AsyncBoundary.js.map +1 -0
  28. package/dist/ui/chrome/EmptyState.d.ts +34 -0
  29. package/dist/ui/chrome/EmptyState.d.ts.map +1 -0
  30. package/dist/ui/chrome/EmptyState.js +27 -0
  31. package/dist/ui/chrome/EmptyState.js.map +1 -0
  32. package/dist/ui/chrome/ErrorCard.d.ts +11 -0
  33. package/dist/ui/chrome/ErrorCard.d.ts.map +1 -0
  34. package/dist/ui/chrome/ErrorCard.js +21 -0
  35. package/dist/ui/chrome/ErrorCard.js.map +1 -0
  36. package/dist/ui/chrome/LoadingState.d.ts +10 -0
  37. package/dist/ui/chrome/LoadingState.d.ts.map +1 -0
  38. package/dist/ui/chrome/LoadingState.js +17 -0
  39. package/dist/ui/chrome/LoadingState.js.map +1 -0
  40. package/dist/ui/chrome/PageHeader.d.ts +20 -0
  41. package/dist/ui/chrome/PageHeader.d.ts.map +1 -0
  42. package/dist/ui/chrome/PageHeader.js +26 -0
  43. package/dist/ui/chrome/PageHeader.js.map +1 -0
  44. package/dist/ui/chrome/StateCard.d.ts +24 -0
  45. package/dist/ui/chrome/StateCard.d.ts.map +1 -0
  46. package/dist/ui/chrome/StateCard.js +17 -0
  47. package/dist/ui/chrome/StateCard.js.map +1 -0
  48. package/dist/ui/cn.d.ts +3 -0
  49. package/dist/ui/cn.d.ts.map +1 -0
  50. package/dist/ui/cn.js +18 -0
  51. package/dist/ui/cn.js.map +1 -0
  52. package/dist/ui/index.d.ts +33 -0
  53. package/dist/ui/index.d.ts.map +1 -0
  54. package/dist/ui/index.js +61 -0
  55. package/dist/ui/index.js.map +1 -0
  56. package/dist/ui/primitives/alert-dialog.d.ts +21 -0
  57. package/dist/ui/primitives/alert-dialog.d.ts.map +1 -0
  58. package/dist/ui/primitives/alert-dialog.js +72 -0
  59. package/dist/ui/primitives/alert-dialog.js.map +1 -0
  60. package/dist/ui/primitives/badge.d.ts +10 -0
  61. package/dist/ui/primitives/badge.d.ts.map +1 -0
  62. package/dist/ui/primitives/badge.js +60 -0
  63. package/dist/ui/primitives/badge.js.map +1 -0
  64. package/dist/ui/primitives/button.d.ts +12 -0
  65. package/dist/ui/primitives/button.d.ts.map +1 -0
  66. package/dist/ui/primitives/button.js +71 -0
  67. package/dist/ui/primitives/button.js.map +1 -0
  68. package/dist/ui/primitives/card.d.ts +9 -0
  69. package/dist/ui/primitives/card.d.ts.map +1 -0
  70. package/dist/ui/primitives/card.js +58 -0
  71. package/dist/ui/primitives/card.js.map +1 -0
  72. package/dist/ui/primitives/checkbox.d.ts +5 -0
  73. package/dist/ui/primitives/checkbox.d.ts.map +1 -0
  74. package/dist/ui/primitives/checkbox.js +45 -0
  75. package/dist/ui/primitives/checkbox.js.map +1 -0
  76. package/dist/ui/primitives/collapsible.d.ts +6 -0
  77. package/dist/ui/primitives/collapsible.d.ts.map +1 -0
  78. package/dist/ui/primitives/collapsible.js +44 -0
  79. package/dist/ui/primitives/collapsible.js.map +1 -0
  80. package/dist/ui/primitives/dialog.d.ts +22 -0
  81. package/dist/ui/primitives/dialog.d.ts.map +1 -0
  82. package/dist/ui/primitives/dialog.js +68 -0
  83. package/dist/ui/primitives/dialog.js.map +1 -0
  84. package/dist/ui/primitives/dropdown-menu.d.ts +28 -0
  85. package/dist/ui/primitives/dropdown-menu.d.ts.map +1 -0
  86. package/dist/ui/primitives/dropdown-menu.js +83 -0
  87. package/dist/ui/primitives/dropdown-menu.js.map +1 -0
  88. package/dist/ui/primitives/input.d.ts +4 -0
  89. package/dist/ui/primitives/input.d.ts.map +1 -0
  90. package/dist/ui/primitives/input.js +45 -0
  91. package/dist/ui/primitives/input.js.map +1 -0
  92. package/dist/ui/primitives/label.d.ts +6 -0
  93. package/dist/ui/primitives/label.d.ts.map +1 -0
  94. package/dist/ui/primitives/label.js +46 -0
  95. package/dist/ui/primitives/label.js.map +1 -0
  96. package/dist/ui/primitives/overflow-tabs.d.ts +18 -0
  97. package/dist/ui/primitives/overflow-tabs.d.ts.map +1 -0
  98. package/dist/ui/primitives/overflow-tabs.js +84 -0
  99. package/dist/ui/primitives/overflow-tabs.js.map +1 -0
  100. package/dist/ui/primitives/popover.d.ts +9 -0
  101. package/dist/ui/primitives/popover.d.ts.map +1 -0
  102. package/dist/ui/primitives/popover.js +48 -0
  103. package/dist/ui/primitives/popover.js.map +1 -0
  104. package/dist/ui/primitives/radio-group.d.ts +6 -0
  105. package/dist/ui/primitives/radio-group.d.ts.map +1 -0
  106. package/dist/ui/primitives/radio-group.js +52 -0
  107. package/dist/ui/primitives/radio-group.js.map +1 -0
  108. package/dist/ui/primitives/resizable.d.ts +12 -0
  109. package/dist/ui/primitives/resizable.d.ts.map +1 -0
  110. package/dist/ui/primitives/resizable.js +18 -0
  111. package/dist/ui/primitives/resizable.js.map +1 -0
  112. package/dist/ui/primitives/scroll-area.d.ts +6 -0
  113. package/dist/ui/primitives/scroll-area.d.ts.map +1 -0
  114. package/dist/ui/primitives/scroll-area.js +47 -0
  115. package/dist/ui/primitives/scroll-area.js.map +1 -0
  116. package/dist/ui/primitives/select.d.ts +14 -0
  117. package/dist/ui/primitives/select.d.ts.map +1 -0
  118. package/dist/ui/primitives/select.js +71 -0
  119. package/dist/ui/primitives/select.js.map +1 -0
  120. package/dist/ui/primitives/separator.d.ts +5 -0
  121. package/dist/ui/primitives/separator.d.ts.map +1 -0
  122. package/dist/ui/primitives/separator.js +44 -0
  123. package/dist/ui/primitives/separator.js.map +1 -0
  124. package/dist/ui/primitives/sheet.d.ts +26 -0
  125. package/dist/ui/primitives/sheet.d.ts.map +1 -0
  126. package/dist/ui/primitives/sheet.js +82 -0
  127. package/dist/ui/primitives/sheet.js.map +1 -0
  128. package/dist/ui/primitives/skeleton.d.ts +13 -0
  129. package/dist/ui/primitives/skeleton.d.ts.map +1 -0
  130. package/dist/ui/primitives/skeleton.js +29 -0
  131. package/dist/ui/primitives/skeleton.js.map +1 -0
  132. package/dist/ui/primitives/switch.d.ts +5 -0
  133. package/dist/ui/primitives/switch.d.ts.map +1 -0
  134. package/dist/ui/primitives/switch.js +44 -0
  135. package/dist/ui/primitives/switch.js.map +1 -0
  136. package/dist/ui/primitives/table.d.ts +11 -0
  137. package/dist/ui/primitives/table.d.ts.map +1 -0
  138. package/dist/ui/primitives/table.js +64 -0
  139. package/dist/ui/primitives/table.js.map +1 -0
  140. package/dist/ui/primitives/tabs.d.ts +8 -0
  141. package/dist/ui/primitives/tabs.d.ts.map +1 -0
  142. package/dist/ui/primitives/tabs.js +52 -0
  143. package/dist/ui/primitives/tabs.js.map +1 -0
  144. package/dist/ui/primitives/tag-multi-select.d.ts +19 -0
  145. package/dist/ui/primitives/tag-multi-select.d.ts.map +1 -0
  146. package/dist/ui/primitives/tag-multi-select.js +92 -0
  147. package/dist/ui/primitives/tag-multi-select.js.map +1 -0
  148. package/dist/ui/primitives/textarea.d.ts +5 -0
  149. package/dist/ui/primitives/textarea.d.ts.map +1 -0
  150. package/dist/ui/primitives/textarea.js +45 -0
  151. package/dist/ui/primitives/textarea.js.map +1 -0
  152. package/dist/ui/primitives/tooltip.d.ts +8 -0
  153. package/dist/ui/primitives/tooltip.d.ts.map +1 -0
  154. package/dist/ui/primitives/tooltip.js +50 -0
  155. package/dist/ui/primitives/tooltip.js.map +1 -0
  156. package/package.json +24 -1
  157. package/src/index.ts +1 -0
  158. package/src/lib/biome-host/host-bridge.ts +10 -0
  159. package/src/lib/capabilities/capability-provider.tsx +95 -0
  160. package/src/lib/capabilities/index.ts +16 -0
  161. package/src/lib/capabilities/types.ts +69 -0
  162. package/src/lib/capabilities/use-capability.ts +72 -0
  163. package/src/ui/chrome/AsyncBoundary.tsx +66 -0
  164. package/src/ui/chrome/EmptyState.tsx +184 -0
  165. package/src/ui/chrome/ErrorCard.tsx +68 -0
  166. package/src/ui/chrome/LoadingState.tsx +61 -0
  167. package/src/ui/chrome/PageHeader.tsx +137 -0
  168. package/src/ui/chrome/StateCard.tsx +150 -0
  169. package/src/ui/cn.ts +32 -0
  170. package/src/ui/index.ts +53 -0
  171. package/src/ui/primitives/alert-dialog.tsx +104 -0
  172. package/src/ui/primitives/badge.tsx +32 -0
  173. package/src/ui/primitives/button.tsx +47 -0
  174. package/src/ui/primitives/card.tsx +43 -0
  175. package/src/ui/primitives/checkbox.tsx +26 -0
  176. package/src/ui/primitives/collapsible.tsx +9 -0
  177. package/src/ui/primitives/dialog.tsx +103 -0
  178. package/src/ui/primitives/dropdown-menu.tsx +179 -0
  179. package/src/ui/primitives/input.tsx +22 -0
  180. package/src/ui/primitives/label.tsx +17 -0
  181. package/src/ui/primitives/overflow-tabs.tsx +281 -0
  182. package/src/ui/primitives/popover.tsx +33 -0
  183. package/src/ui/primitives/radio-group.tsx +36 -0
  184. package/src/ui/primitives/resizable.tsx +67 -0
  185. package/src/ui/primitives/scroll-area.tsx +38 -0
  186. package/src/ui/primitives/select.tsx +143 -0
  187. package/src/ui/primitives/separator.tsx +20 -0
  188. package/src/ui/primitives/sheet.tsx +107 -0
  189. package/src/ui/primitives/skeleton.tsx +99 -0
  190. package/src/ui/primitives/switch.tsx +27 -0
  191. package/src/ui/primitives/table.tsx +72 -0
  192. package/src/ui/primitives/tabs.tsx +53 -0
  193. package/src/ui/primitives/tag-multi-select.tsx +241 -0
  194. package/src/ui/primitives/textarea.tsx +21 -0
  195. package/src/ui/primitives/tooltip.tsx +30 -0
@@ -0,0 +1,69 @@
1
+ import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
2
+
3
+ /**
4
+ * One server-authoritative capability the caller holds, mirroring the
5
+ * FROZEN `GET /bff/me/capabilities` wire entry.
6
+ *
7
+ * These types are defined LOCALLY in the kernel — deliberately NOT imported
8
+ * from a backend DTO or an Orval client — so `@xemahq/ui-kernel` stays
9
+ * host-agnostic (no fetch lib, no generated client). The concrete host
10
+ * (`submodules/xema-host-web`) maps its client response onto this shape when
11
+ * it implements {@link CapabilityPort}.
12
+ */
13
+ export interface CapabilityEntry {
14
+ /**
15
+ * The capability reference (e.g. `capability:session.launch`). Matched
16
+ * verbatim by {@link useCapability}; the kernel does not parse it.
17
+ */
18
+ readonly ref: string;
19
+ /**
20
+ * The execution environments in which the caller holds this capability.
21
+ * A gate that names an `environment` is allowed only if it appears here.
22
+ */
23
+ readonly environments: ExecutionEnvironmentKind[];
24
+ /**
25
+ * When `true`, holding the capability still requires a human approval step
26
+ * before the side-effect runs — so a UI affordance should route through the
27
+ * approval flow rather than acting directly.
28
+ */
29
+ readonly approvalRequired: boolean;
30
+ }
31
+
32
+ /**
33
+ * The caller's effective capability set for one active org, mirroring the
34
+ * FROZEN `MeCapabilitiesResponse` wire shape.
35
+ */
36
+ export interface MeCapabilitiesSnapshot {
37
+ /** The subject (user) the snapshot was resolved for. */
38
+ readonly subjectId: string;
39
+ /** The org the snapshot is scoped to. */
40
+ readonly orgId: string;
41
+ /** Every capability the subject effectively holds in this org. */
42
+ readonly capabilities: CapabilityEntry[];
43
+ }
44
+
45
+ /**
46
+ * Host-implemented port that fetches the caller's effective capability set.
47
+ *
48
+ * The kernel never calls an API directly — the HOST implements this against
49
+ * its Orval client (`GET /bff/me/capabilities`) and maps the response onto
50
+ * {@link MeCapabilitiesSnapshot}. The implementation is hung off
51
+ * {@link HostBridge.capabilities}; {@link CapabilityProvider} reads it from
52
+ * the bridge and uses the bridge's `QueryClient` for caching/refetch.
53
+ */
54
+ export interface CapabilityPort {
55
+ /**
56
+ * Return the caller's effective capability set for the active org.
57
+ *
58
+ * @param options.orgId The org to resolve for. When omitted the host
59
+ * resolves against its currently-selected org.
60
+ * @param options.environment Optional environment hint the host MAY pass to
61
+ * the backend to narrow the set. Callers gate by environment client-side
62
+ * via {@link useCapability}, so a host MAY ignore this and return the full
63
+ * set; it exists so a host can request a pre-filtered snapshot.
64
+ */
65
+ list(options?: {
66
+ orgId?: string;
67
+ environment?: ExecutionEnvironmentKind;
68
+ }): Promise<MeCapabilitiesSnapshot>;
69
+ }
@@ -0,0 +1,72 @@
1
+ import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
2
+
3
+ import { useCapabilityContext, type CapabilityContextValue } from './capability-provider';
4
+ import type { MeCapabilitiesSnapshot } from './types';
5
+
6
+ export interface UseCapabilityOptions {
7
+ /**
8
+ * Gate against a specific execution environment. When set, the capability
9
+ * counts only if the snapshot lists it for this environment.
10
+ */
11
+ readonly environment?: ExecutionEnvironmentKind;
12
+ }
13
+
14
+ export interface CapabilityDecision {
15
+ /**
16
+ * `true` only when the caller holds the capability, holds it in the named
17
+ * environment (if one was given), AND it does NOT require approval. A gate
18
+ * that should let the user act directly checks this.
19
+ */
20
+ readonly allowed: boolean;
21
+ /**
22
+ * `true` when the caller holds the capability (in the named environment, if
23
+ * given) but it requires a human approval step first — render the
24
+ * approval-routed affordance instead of acting directly.
25
+ */
26
+ readonly approvalRequired: boolean;
27
+ /** The snapshot is still being fetched; treat as "unknown", not "denied". */
28
+ readonly isLoading: boolean;
29
+ /** The fetch failed; surfaced so callers never conflate failure with denial. */
30
+ readonly error: unknown;
31
+ }
32
+
33
+ /**
34
+ * Server-authoritative capability gate.
35
+ *
36
+ * Resolves `ref` against the cached {@link CapabilityProvider} snapshot:
37
+ * - `allowed` = entry exists AND (no environment asked OR listed for it) AND NOT approvalRequired.
38
+ * - `approvalRequired` = entry exists AND (no environment asked OR listed for it) AND approvalRequired.
39
+ *
40
+ * While the snapshot is loading, `allowed` is `false` and `isLoading` is
41
+ * `true` — callers gate on `isLoading` to distinguish "unknown" from "denied".
42
+ * On error, `allowed` is `false` and `error` is set; the hook never silently
43
+ * degrades a failed fetch into a confident denial.
44
+ */
45
+ export function useCapability(ref: string, opts?: UseCapabilityOptions): CapabilityDecision {
46
+ const { snapshot, isLoading, error } = useCapabilityContext();
47
+
48
+ const entry = snapshot?.capabilities.find((c) => c.ref === ref);
49
+ const inEnvironment =
50
+ !!entry && (!opts?.environment || entry.environments.includes(opts.environment));
51
+
52
+ return {
53
+ allowed: inEnvironment && !entry.approvalRequired,
54
+ approvalRequired: inEnvironment && entry.approvalRequired,
55
+ isLoading,
56
+ error,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Accessor for the raw capability snapshot plus its loading/error state.
62
+ * Use when a component needs to enumerate capabilities rather than gate on a
63
+ * single `ref`.
64
+ */
65
+ export function useCapabilities(): {
66
+ snapshot: MeCapabilitiesSnapshot | undefined;
67
+ isLoading: boolean;
68
+ error: unknown;
69
+ } {
70
+ const ctx: CapabilityContextValue = useCapabilityContext();
71
+ return { snapshot: ctx.snapshot, isLoading: ctx.isLoading, error: ctx.error };
72
+ }
@@ -0,0 +1,66 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import ErrorCard, { type ErrorMessageFormatter } from './ErrorCard';
5
+ import EmptyState from './EmptyState';
6
+ import LoadingState, { type LoadingStateVariant } from './LoadingState';
7
+
8
+ interface AsyncBoundaryEmpty {
9
+ icon: LucideIcon;
10
+ title: string;
11
+ description?: ReactNode;
12
+ }
13
+
14
+ interface AsyncBoundaryProps {
15
+ /** Async in flight — renders the loading affordance. */
16
+ isLoading: boolean;
17
+ /** Resolved error, if any — renders an `ErrorCard`. */
18
+ error?: Error | string | unknown | null;
19
+ /**
20
+ * When true, the resolved data is empty — renders an `EmptyState`. Only
21
+ * checked when `empty` config is supplied; otherwise children render.
22
+ */
23
+ isEmpty?: boolean;
24
+ /** Empty-state config; omit to never render an empty state. */
25
+ empty?: AsyncBoundaryEmpty;
26
+ /** Retry handler threaded to `ErrorCard`. */
27
+ onRetry?: () => void;
28
+ /** Loading affordance variant. */
29
+ loadingVariant?: LoadingStateVariant;
30
+ /** Host error decoder threaded to `ErrorCard`. */
31
+ formatError?: ErrorMessageFormatter;
32
+ children: ReactNode;
33
+ }
34
+
35
+ /**
36
+ * Declarative loading / error / empty wrapper. Collapses the
37
+ * `if (isLoading) … if (error) … if (empty) … else children` ladder that
38
+ * every biome list/detail surface re-implements into one component built on
39
+ * the shared `LoadingState` / `ErrorCard` / `EmptyState` primitives.
40
+ *
41
+ * Precedence: loading → error → empty → children.
42
+ */
43
+ export default function AsyncBoundary({
44
+ isLoading,
45
+ error,
46
+ isEmpty,
47
+ empty,
48
+ onRetry,
49
+ loadingVariant,
50
+ formatError,
51
+ children,
52
+ }: Readonly<AsyncBoundaryProps>) {
53
+ if (isLoading) {
54
+ return <LoadingState variant={loadingVariant} />;
55
+ }
56
+
57
+ if (error) {
58
+ return <ErrorCard error={error} onRetry={onRetry} formatError={formatError} />;
59
+ }
60
+
61
+ if (isEmpty && empty) {
62
+ return <EmptyState icon={empty.icon} title={empty.title} description={empty.description} />;
63
+ }
64
+
65
+ return <>{children}</>;
66
+ }
@@ -0,0 +1,184 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import type { ComponentType, ReactNode } from 'react';
3
+
4
+ import { cn } from '../cn';
5
+ import { Button } from '../primitives/button';
6
+ import { Card, CardContent } from '../primitives/card';
7
+
8
+ export type EmptyStateVariant = 'not-configured' | 'no-results' | 'disabled-context';
9
+
10
+ /**
11
+ * Visual density.
12
+ * - `comfortable` (default): hero empty state for full-page surfaces —
13
+ * big 64px icon, generous padding, hero copy. Use when the page has
14
+ * nothing else to render.
15
+ * - `dense`: inline empty state for surfaces nested inside other
16
+ * content (tabs, sub-panels). Smaller icon, tighter padding, no
17
+ * dashed card frame — reads as a calm placeholder, not a banner.
18
+ * - `inline`: single-line text-only message, no icon, no card. Use
19
+ * inside list sub-sections where even the dense frame is too much.
20
+ */
21
+ export type EmptyStateSize = 'comfortable' | 'dense' | 'inline';
22
+
23
+ /**
24
+ * Polymorphic link element. The host shell renders Next.js `<Link>`;
25
+ * pass it via `linkComponent` to get client-side routing. Defaults to a
26
+ * plain anchor so the primitive stays host-framework-agnostic.
27
+ */
28
+ export type EmptyStateLinkComponent = ComponentType<{
29
+ href: string;
30
+ children: ReactNode;
31
+ }>;
32
+
33
+ const DefaultLink: EmptyStateLinkComponent = ({ href, children }) => (
34
+ <a href={href}>{children}</a>
35
+ );
36
+
37
+ interface EmptyStateAction {
38
+ label: string;
39
+ onClick: () => void;
40
+ icon?: LucideIcon;
41
+ }
42
+
43
+ interface EmptyStateSecondaryAction {
44
+ label: string;
45
+ href: string;
46
+ }
47
+
48
+ interface EmptyStateProps {
49
+ icon: LucideIcon;
50
+ title: string;
51
+ description?: ReactNode;
52
+ /** Optional illustration or extra content above the title */
53
+ illustration?: ReactNode;
54
+ /** Visual variant — drives default iconBg/iconColor unless overridden. */
55
+ variant?: EmptyStateVariant;
56
+ size?: EmptyStateSize;
57
+ /** Icon tint — overrides variant defaults */
58
+ iconBg?: string;
59
+ iconColor?: string;
60
+ action?: EmptyStateAction;
61
+ secondaryAction?: EmptyStateSecondaryAction;
62
+ /** Host link component for `secondaryAction.href` (defaults to `<a>`). */
63
+ linkComponent?: EmptyStateLinkComponent;
64
+ className?: string;
65
+ }
66
+
67
+ const VARIANT_DEFAULTS: Record<EmptyStateVariant, { iconBg: string; iconColor: string }> = {
68
+ 'not-configured': { iconBg: 'bg-info/10', iconColor: 'text-info' },
69
+ 'no-results': { iconBg: 'bg-warning/10', iconColor: 'text-warning' },
70
+ 'disabled-context': { iconBg: 'bg-muted', iconColor: 'text-ink-3' },
71
+ };
72
+
73
+ export default function EmptyState({
74
+ icon: Icon,
75
+ title,
76
+ description,
77
+ illustration,
78
+ variant = 'not-configured',
79
+ size = 'comfortable',
80
+ iconBg,
81
+ iconColor,
82
+ action,
83
+ secondaryAction,
84
+ linkComponent,
85
+ className,
86
+ }: Readonly<EmptyStateProps>) {
87
+ const Link = linkComponent ?? DefaultLink;
88
+ const defaults = VARIANT_DEFAULTS[variant];
89
+ const resolvedIconBg = iconBg ?? defaults.iconBg;
90
+ const resolvedIconColor = iconColor ?? defaults.iconColor;
91
+
92
+ if (size === 'inline') {
93
+ return (
94
+ <div className={cn('flex items-center justify-center px-3 py-6 text-center text-body-2 text-ink-4', className)}>
95
+ <div className="max-w-sm space-y-1">
96
+ <p className="text-ink-3">{title}</p>
97
+ {description && <p className="text-ink-4">{description}</p>}
98
+ {(action || secondaryAction) && (
99
+ <div className="mt-3 flex items-center justify-center gap-2">
100
+ {action && (
101
+ <Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
102
+ {action.icon && <action.icon className="h-3.5 w-3.5" />}
103
+ {action.label}
104
+ </Button>
105
+ )}
106
+ {secondaryAction && (
107
+ <Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
108
+ <Link href={secondaryAction.href}>{secondaryAction.label}</Link>
109
+ </Button>
110
+ )}
111
+ </div>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ if (size === 'dense') {
119
+ return (
120
+ <div
121
+ className={cn(
122
+ 'flex flex-col items-center justify-center px-4 py-8 text-center',
123
+ className,
124
+ )}
125
+ >
126
+ {illustration ?? (
127
+ <div className={cn('h-9 w-9 rounded-xl flex items-center justify-center mb-3', resolvedIconBg)}>
128
+ <Icon className={cn('h-4 w-4', resolvedIconColor)} />
129
+ </div>
130
+ )}
131
+ <p className="text-body-1 font-semibold text-ink">{title}</p>
132
+ {description && (
133
+ <p className="mt-1 max-w-md text-body-2 leading-snug text-ink-3">{description}</p>
134
+ )}
135
+ {(action || secondaryAction) && (
136
+ <div className="mt-3 flex items-center gap-2">
137
+ {action && (
138
+ <Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
139
+ {action.icon && <action.icon className="h-3.5 w-3.5" />}
140
+ {action.label}
141
+ </Button>
142
+ )}
143
+ {secondaryAction && (
144
+ <Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
145
+ <Link href={secondaryAction.href}>{secondaryAction.label}</Link>
146
+ </Button>
147
+ )}
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <Card className={cn('border-2 border-dashed border-border/50 rounded-xl', className)}>
156
+ <CardContent className="flex flex-col items-center justify-center py-16 text-center">
157
+ {illustration ?? (
158
+ <div className={`h-16 w-16 rounded-2xl flex items-center justify-center mb-5 ${resolvedIconBg}`}>
159
+ <Icon className={`h-7 w-7 ${resolvedIconColor}`} />
160
+ </div>
161
+ )}
162
+ <p className="text-subtitle font-semibold text-ink">{title}</p>
163
+ {description && (
164
+ <p className="text-body-1 mt-2 max-w-sm text-ink-3 leading-relaxed">{description}</p>
165
+ )}
166
+ {(action || secondaryAction) && (
167
+ <div className="mt-6 flex items-center gap-3">
168
+ {action && (
169
+ <Button size="sm" variant="outline" onClick={action.onClick} className="gap-2 h-10 px-5">
170
+ {action.icon && <action.icon className="h-4 w-4" />}
171
+ {action.label}
172
+ </Button>
173
+ )}
174
+ {secondaryAction && (
175
+ <Button asChild size="sm" variant="ghost" className="h-10 px-3">
176
+ <Link href={secondaryAction.href}>{secondaryAction.label}</Link>
177
+ </Button>
178
+ )}
179
+ </div>
180
+ )}
181
+ </CardContent>
182
+ </Card>
183
+ );
184
+ }
@@ -0,0 +1,68 @@
1
+ import { AlertCircle, RefreshCw } from 'lucide-react';
2
+
3
+ import { Button } from '../primitives/button';
4
+ import { Card, CardContent } from '../primitives/card';
5
+
6
+ /**
7
+ * Host-agnostic error formatter. The host shell owns rich error decoding
8
+ * (Orval envelopes, workflow error codes, …); pass its formatter via
9
+ * `formatError` to preserve that behaviour. The default extracts a plain
10
+ * message and is safe in any consumer.
11
+ */
12
+ export type ErrorMessageFormatter = (error: unknown, fallback: string) => string;
13
+
14
+ const defaultFormatError: ErrorMessageFormatter = (error, fallback) => {
15
+ if (typeof error === 'string') {
16
+ return error || fallback;
17
+ }
18
+ if (error instanceof Error && error.message) {
19
+ return error.message;
20
+ }
21
+ return fallback;
22
+ };
23
+
24
+ interface ErrorCardProps {
25
+ error: Error | string | unknown;
26
+ title?: string;
27
+ onRetry?: () => void;
28
+ /**
29
+ * Optional host error-decoder. Defaults to a plain message extractor.
30
+ * The host shell should pass `getUserFacingErrorMessage` to keep its
31
+ * envelope-aware messaging.
32
+ */
33
+ formatError?: ErrorMessageFormatter;
34
+ fallbackMessage?: string;
35
+ }
36
+
37
+ export default function ErrorCard({
38
+ error,
39
+ title = 'Something went wrong',
40
+ onRetry,
41
+ formatError = defaultFormatError,
42
+ fallbackMessage = 'Unable to load this content right now.',
43
+ }: ErrorCardProps) {
44
+ const message = formatError(error, fallbackMessage);
45
+
46
+ return (
47
+ <Card className="border-destructive/25 bg-destructive/[0.03] rounded-xl">
48
+ <CardContent className="flex flex-col items-center py-14 text-center">
49
+ <div className="h-14 w-14 rounded-2xl bg-destructive/10 flex items-center justify-center mb-4">
50
+ <AlertCircle className="h-6 w-6 text-destructive" />
51
+ </div>
52
+ <p className="text-subtitle font-semibold text-ink">{title}</p>
53
+ <p className="text-body-1 text-ink-3 mt-2 max-w-md leading-relaxed">{message}</p>
54
+ {onRetry && (
55
+ <Button
56
+ size="sm"
57
+ variant="outline"
58
+ onClick={onRetry}
59
+ className="mt-5 gap-2 h-10 border-destructive/20 text-destructive hover:bg-destructive/5"
60
+ >
61
+ <RefreshCw className="h-4 w-4" />
62
+ Retry
63
+ </Button>
64
+ )}
65
+ </CardContent>
66
+ </Card>
67
+ );
68
+ }
@@ -0,0 +1,61 @@
1
+ import { Loader2 } from 'lucide-react';
2
+
3
+ import { cn } from '../cn';
4
+ import { Skeleton } from '../primitives/skeleton';
5
+
6
+ export type LoadingStateVariant = 'spinner' | 'skeleton-list' | 'skeleton-card';
7
+
8
+ interface LoadingStateProps {
9
+ /**
10
+ * `spinner` (default): centred spinner + optional label. `skeleton-list`:
11
+ * N skeleton rows. `skeleton-card`: a single card-shaped skeleton block.
12
+ */
13
+ variant?: LoadingStateVariant;
14
+ /** Accessible label / caption (spinner variant). */
15
+ label?: string;
16
+ /** Row count for `skeleton-list` (default 4). */
17
+ rows?: number;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Canonical loading affordance shared across biomes. Replaces the per-biome
23
+ * ad-hoc spinners and inline skeleton stacks.
24
+ */
25
+ export default function LoadingState({
26
+ variant = 'spinner',
27
+ label = 'Loading…',
28
+ rows = 4,
29
+ className,
30
+ }: Readonly<LoadingStateProps>) {
31
+ if (variant === 'skeleton-list') {
32
+ return (
33
+ <div className={cn('space-y-3', className)} role="status" aria-label={label}>
34
+ {Array.from({ length: rows }).map((_, i) => (
35
+ <Skeleton key={i} className="h-12 w-full rounded-xl" />
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ if (variant === 'skeleton-card') {
42
+ return (
43
+ <div className={cn('space-y-3', className)} role="status" aria-label={label}>
44
+ <Skeleton className="h-40 w-full rounded-2xl" />
45
+ <Skeleton className="h-4 w-2/3 rounded" />
46
+ <Skeleton className="h-4 w-1/2 rounded" />
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div
53
+ className={cn('flex flex-col items-center justify-center py-14 text-center', className)}
54
+ role="status"
55
+ aria-label={label}
56
+ >
57
+ <Loader2 className="h-6 w-6 animate-spin text-ink-3" />
58
+ {label && <p className="mt-3 text-body-2 text-ink-3">{label}</p>}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,137 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── PageHeader — INVISIBLE topbar registration ──
3
+ //
4
+ // PageHeader does NOT render a visible header bar. It registers the page's
5
+ // title, description, eyebrow, help-actions, status pill, count, and
6
+ // primary action buttons into the shared page-meta store. The global
7
+ // `AppTopbar` reads from that store and renders them in the topbar
8
+ // (mac-style — the topbar IS the page chrome).
9
+ //
10
+ // Why invisible: stacking a second header row beneath the topbar wastes
11
+ // vertical space and competes with the topbar for attention. Pages get
12
+ // the canvas back; the topbar carries context.
13
+ //
14
+ // When you need a thin in-page row (filter dropdowns, search input,
15
+ // selection-mode toolbar), use `<PageToolbar>` from this same file —
16
+ // that's a real visible row but carries NO title (the topbar still owns
17
+ // it), so there's no visual duplication.
18
+ //
19
+ // Host-agnostic: reaches the page-meta store through the kernel `HostBridge`
20
+ // singleton (NOT a host import), so it bundles cleanly into any biome remote.
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+
23
+ import { useMemo, type ReactNode } from 'react';
24
+
25
+ import { useHostBridge, type PageBackTarget } from '../../lib/biome-host';
26
+ import { cn } from '../cn';
27
+
28
+ interface PageHeaderProps {
29
+ readonly title: string;
30
+ /**
31
+ * Short secondary line. Renders in the topbar info popover (long-form
32
+ * `topbarDescription` wins for the popover if both are set) AND
33
+ * inline next to the title as `topbarMeta` when it's a string.
34
+ */
35
+ readonly description?: string;
36
+ /**
37
+ * Long-form text rendered ONLY in the topbar's info popover (the
38
+ * (i) button). Use when `description` is a counts line and you still
39
+ * want a richer popover blurb.
40
+ */
41
+ readonly topbarDescription?: string;
42
+ /** Mono caption rendered before the title in the topbar info popover. */
43
+ readonly eyebrow?: string;
44
+ /** Bullet list of "what you can do here" shown in the topbar info popover. */
45
+ readonly helpActions?: readonly string[];
46
+ /**
47
+ * Right-aligned topbar action cluster. Use small h-7 buttons / icon
48
+ * buttons. Anything that needs more space (multi-input filter rows,
49
+ * search inputs) belongs in a `<PageToolbar>` inside the page body
50
+ * — not here.
51
+ */
52
+ readonly actions?: ReactNode;
53
+ /**
54
+ * Leftmost back affordance. Renders as an icon-only button before
55
+ * the title in the topbar. Pages with a parent context (a detail
56
+ * page under a list, etc.) set this so the user can return to where
57
+ * they came from. See `app/page-meta.ts` for the full layout
58
+ * contract — back always lives on the left, never inside the page
59
+ * body.
60
+ */
61
+ readonly backTo?: PageBackTarget;
62
+ }
63
+
64
+ /**
65
+ * Invisible primitive — registers page meta with the topbar. Renders
66
+ * nothing visible. Mount once at the top of a page component.
67
+ */
68
+ export default function PageHeader({
69
+ title,
70
+ description,
71
+ topbarDescription,
72
+ eyebrow,
73
+ helpActions,
74
+ actions,
75
+ backTo,
76
+ }: Readonly<PageHeaderProps>) {
77
+ // `topbarActions` is a ReactNode — wrap in useMemo so React doesn't see
78
+ // a new identity every render and re-notify the store needlessly. Same
79
+ // for the meta line.
80
+ const topbarActions = useMemo(() => actions, [actions]);
81
+ const topbarMeta = useMemo<ReactNode>(
82
+ () => (description ? <span>{description}</span> : null),
83
+ [description],
84
+ );
85
+
86
+ // Reach the host page-meta store through the shared bridge singleton — NOT
87
+ // a module-level import — so this component bundles cleanly into an MF
88
+ // remote and still drives the one host topbar store.
89
+ useHostBridge().pageMeta.usePageMeta({
90
+ title,
91
+ eyebrow,
92
+ description: topbarDescription ?? description,
93
+ actions: helpActions,
94
+ topbarActions,
95
+ topbarMeta,
96
+ backTo,
97
+ });
98
+ return null;
99
+ }
100
+
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+ // ── PageToolbar — thin in-page row for filters / search / sub-tabs ──
103
+ //
104
+ // Use when the page needs a horizontal row of controls that don't fit
105
+ // in the topbar (multi-select filter, search input, bulk-selection
106
+ // toolbar). NEVER include the page title — that's the topbar's job.
107
+ //
108
+ // Default height is `h-9` to match the topbar visual rhythm without
109
+ // looking like a duplicated header. Background is the same paper tone
110
+ // as the page so it reads as a tool strip, not a banner.
111
+ // ═══════════════════════════════════════════════════════════════════════════
112
+
113
+ interface PageToolbarProps {
114
+ /** Left cluster — filters, search, etc. */
115
+ readonly children?: ReactNode;
116
+ /** Right cluster — usually `null`; the primary action lives in the topbar. */
117
+ readonly trailing?: ReactNode;
118
+ readonly className?: string;
119
+ }
120
+
121
+ export function PageToolbar({
122
+ children,
123
+ trailing,
124
+ className,
125
+ }: Readonly<PageToolbarProps>) {
126
+ return (
127
+ <div
128
+ className={cn(
129
+ 'flex shrink-0 items-center gap-2 border-b border-rule/50 bg-paper px-4 py-1.5 sm:px-6',
130
+ className,
131
+ )}
132
+ >
133
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{children}</div>
134
+ {trailing && <div className="flex shrink-0 items-center gap-1.5">{trailing}</div>}
135
+ </div>
136
+ );
137
+ }