@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/define-web-biome.d.ts +3 -2
- package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/define-web-biome.js +23 -1
- package/dist/lib/biome-host/define-web-biome.js.map +1 -1
- package/dist/lib/biome-host/frontend-biome.d.ts +4 -1
- package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.d.ts +2 -0
- package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.js.map +1 -1
- package/dist/lib/biome-host/i18n-hooks.d.ts +4 -0
- package/dist/lib/biome-host/i18n-hooks.d.ts.map +1 -0
- package/dist/lib/biome-host/i18n-hooks.js +22 -0
- package/dist/lib/biome-host/i18n-hooks.js.map +1 -0
- package/dist/lib/biome-host/i18n-port.d.ts +15 -0
- package/dist/lib/biome-host/i18n-port.d.ts.map +1 -0
- package/dist/lib/biome-host/i18n-port.js +29 -0
- package/dist/lib/biome-host/i18n-port.js.map +1 -0
- package/dist/lib/biome-host/index.d.ts +2 -0
- package/dist/lib/biome-host/index.d.ts.map +1 -1
- package/dist/lib/biome-host/index.js +2 -0
- package/dist/lib/biome-host/index.js.map +1 -1
- package/dist/lib/biome-host/realtime-port.d.ts +1 -1
- package/dist/lib/biome-host/realtime-port.d.ts.map +1 -1
- package/dist/session-kit/approvals/approval-model.d.ts.map +1 -1
- package/dist/session-kit/approvals/approval-model.js.map +1 -1
- package/dist/ui/branding/app-branding-provider.d.ts +17 -0
- package/dist/ui/branding/app-branding-provider.d.ts.map +1 -0
- package/dist/ui/branding/app-branding-provider.js +81 -0
- package/dist/ui/branding/app-branding-provider.js.map +1 -0
- package/dist/ui/branding/sanitize.d.ts +4 -0
- package/dist/ui/branding/sanitize.d.ts.map +1 -0
- package/dist/ui/branding/sanitize.js +47 -0
- package/dist/ui/branding/sanitize.js.map +1 -0
- package/dist/ui/breakpoints.d.ts +2 -0
- package/dist/ui/breakpoints.d.ts.map +1 -0
- package/dist/ui/breakpoints.js +5 -0
- package/dist/ui/breakpoints.js.map +1 -0
- package/dist/ui/capture/document-preview.d.ts +12 -0
- package/dist/ui/capture/document-preview.d.ts.map +1 -0
- package/dist/ui/capture/document-preview.js +31 -0
- package/dist/ui/capture/document-preview.js.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts +5 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.js +71 -0
- package/dist/ui/capture/field-spec-to-zod.js.map +1 -0
- package/dist/ui/capture/field-spec.d.ts +49 -0
- package/dist/ui/capture/field-spec.d.ts.map +1 -0
- package/dist/ui/capture/field-spec.js +59 -0
- package/dist/ui/capture/field-spec.js.map +1 -0
- package/dist/ui/capture/index.d.ts +11 -0
- package/dist/ui/capture/index.d.ts.map +1 -0
- package/dist/ui/capture/index.js +31 -0
- package/dist/ui/capture/index.js.map +1 -0
- package/dist/ui/capture/schema-field-input.d.ts +20 -0
- package/dist/ui/capture/schema-field-input.d.ts.map +1 -0
- package/dist/ui/capture/schema-field-input.js +64 -0
- package/dist/ui/capture/schema-field-input.js.map +1 -0
- package/dist/ui/capture/schema-form.d.ts +18 -0
- package/dist/ui/capture/schema-form.d.ts.map +1 -0
- package/dist/ui/capture/schema-form.js +26 -0
- package/dist/ui/capture/schema-form.js.map +1 -0
- package/dist/ui/capture/use-prefill.d.ts +18 -0
- package/dist/ui/capture/use-prefill.d.ts.map +1 -0
- package/dist/ui/capture/use-prefill.js +94 -0
- package/dist/ui/capture/use-prefill.js.map +1 -0
- package/dist/ui/capture/use-schema-validation.d.ts +11 -0
- package/dist/ui/capture/use-schema-validation.d.ts.map +1 -0
- package/dist/ui/capture/use-schema-validation.js +69 -0
- package/dist/ui/capture/use-schema-validation.js.map +1 -0
- package/dist/ui/capture/use-wizard.d.ts +28 -0
- package/dist/ui/capture/use-wizard.d.ts.map +1 -0
- package/dist/ui/capture/use-wizard.js +63 -0
- package/dist/ui/capture/use-wizard.js.map +1 -0
- package/dist/ui/capture/value-format.d.ts +4 -0
- package/dist/ui/capture/value-format.d.ts.map +1 -0
- package/dist/ui/capture/value-format.js +23 -0
- package/dist/ui/capture/value-format.js.map +1 -0
- package/dist/ui/capture/wizard.d.ts +19 -0
- package/dist/ui/capture/wizard.d.ts.map +1 -0
- package/dist/ui/capture/wizard.js +34 -0
- package/dist/ui/capture/wizard.js.map +1 -0
- package/dist/ui/charts/bar-chart.d.ts +7 -0
- package/dist/ui/charts/bar-chart.d.ts.map +1 -0
- package/dist/ui/charts/bar-chart.js +47 -0
- package/dist/ui/charts/bar-chart.js.map +1 -0
- package/dist/ui/charts/chart-frame.d.ts +25 -0
- package/dist/ui/charts/chart-frame.d.ts.map +1 -0
- package/dist/ui/charts/chart-frame.js +23 -0
- package/dist/ui/charts/chart-frame.js.map +1 -0
- package/dist/ui/charts/chart-types.d.ts +19 -0
- package/dist/ui/charts/chart-types.d.ts.map +1 -0
- package/dist/ui/charts/chart-types.js +3 -0
- package/dist/ui/charts/chart-types.js.map +1 -0
- package/dist/ui/charts/chart-utils.d.ts +22 -0
- package/dist/ui/charts/chart-utils.d.ts.map +1 -0
- package/dist/ui/charts/chart-utils.js +97 -0
- package/dist/ui/charts/chart-utils.js.map +1 -0
- package/dist/ui/charts/donut-chart.d.ts +7 -0
- package/dist/ui/charts/donut-chart.d.ts.map +1 -0
- package/dist/ui/charts/donut-chart.js +55 -0
- package/dist/ui/charts/donut-chart.js.map +1 -0
- package/dist/ui/charts/index.d.ts +8 -0
- package/dist/ui/charts/index.d.ts.map +1 -0
- package/dist/ui/charts/index.js +21 -0
- package/dist/ui/charts/index.js.map +1 -0
- package/dist/ui/charts/line-chart.d.ts +7 -0
- package/dist/ui/charts/line-chart.d.ts.map +1 -0
- package/dist/ui/charts/line-chart.js +70 -0
- package/dist/ui/charts/line-chart.js.map +1 -0
- package/dist/ui/charts/sparkline.d.ts +13 -0
- package/dist/ui/charts/sparkline.d.ts.map +1 -0
- package/dist/ui/charts/sparkline.js +39 -0
- package/dist/ui/charts/sparkline.js.map +1 -0
- package/dist/ui/hooks/use-is-mobile.d.ts +2 -0
- package/dist/ui/hooks/use-is-mobile.d.ts.map +1 -0
- package/dist/ui/hooks/use-is-mobile.js +19 -0
- package/dist/ui/hooks/use-is-mobile.js.map +1 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +15 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/pdf/pdf-preview.d.ts +8 -0
- package/dist/ui/pdf/pdf-preview.d.ts.map +1 -0
- package/dist/ui/pdf/pdf-preview.js +34 -0
- package/dist/ui/pdf/pdf-preview.js.map +1 -0
- package/dist/ui/primitives/badge.d.ts +1 -1
- package/dist/ui/primitives/button.d.ts +1 -1
- package/dist/ui/primitives/preview-device-frame.d.ts +9 -0
- package/dist/ui/primitives/preview-device-frame.d.ts.map +1 -0
- package/dist/ui/primitives/preview-device-frame.js +90 -0
- package/dist/ui/primitives/preview-device-frame.js.map +1 -0
- package/dist/ui/primitives/sheet.d.ts +1 -1
- package/package.json +10 -8
- package/src/index.ts +5 -0
- package/src/lib/biome-host/define-web-biome.test.js +93 -0
- package/src/lib/biome-host/define-web-biome.ts +71 -4
- package/src/lib/biome-host/frontend-biome.ts +45 -4
- package/src/lib/biome-host/host-bridge.ts +13 -0
- package/src/lib/biome-host/i18n-hooks.ts +50 -0
- package/src/lib/biome-host/i18n-port.test.ts +50 -0
- package/src/lib/biome-host/i18n-port.ts +96 -0
- package/src/lib/biome-host/index.ts +2 -0
- package/src/lib/biome-host/realtime-port.ts +9 -3
- package/src/lib/system-bus/enums.ts +1 -1
- package/src/lib/system-bus/index.ts +1 -1
- package/src/lib/system-bus/intents.ts +1 -1
- package/src/session-kit/approvals/ApprovalCard.tsx +1 -1
- package/src/session-kit/approvals/ApprovalsCenter.tsx +2 -2
- package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +1 -1
- package/src/session-kit/approvals/Obligation.tsx +1 -1
- package/src/session-kit/approvals/ScopedBody.tsx +1 -1
- package/src/session-kit/approvals/approval-model.ts +7 -8
- package/src/session-kit/approvals/index.ts +1 -1
- package/src/session-kit/approvals/obligation-display.ts +1 -1
- package/src/session-kit/approvals/risk-accent.ts +1 -1
- package/src/session-kit/combobox/Combobox.tsx +1 -1
- package/src/session-kit/display/MessageTurn.tsx +1 -1
- package/src/session-kit/display/TokenMeter.tsx +1 -1
- package/src/session-kit/index.ts +3 -3
- package/src/session-kit/lib/enums.ts +1 -1
- package/src/session-kit/lib/status-dot.ts +1 -1
- package/src/session-kit/primitives/RiskPill.tsx +1 -1
- package/src/ui/branding/app-branding-provider.tsx +146 -0
- package/src/ui/branding/sanitize.ts +77 -0
- package/src/ui/breakpoints.ts +14 -0
- package/src/ui/capture/document-preview.tsx +101 -0
- package/src/ui/capture/field-spec-to-zod.test.ts +59 -0
- package/src/ui/capture/field-spec-to-zod.ts +93 -0
- package/src/ui/capture/field-spec.test.ts +49 -0
- package/src/ui/capture/field-spec.ts +160 -0
- package/src/ui/capture/index.ts +43 -0
- package/src/ui/capture/schema-field-input.tsx +186 -0
- package/src/ui/capture/schema-form.tsx +108 -0
- package/src/ui/capture/use-prefill.ts +150 -0
- package/src/ui/capture/use-schema-validation.ts +113 -0
- package/src/ui/capture/use-wizard.ts +134 -0
- package/src/ui/capture/value-format.ts +22 -0
- package/src/ui/capture/wizard.tsx +100 -0
- package/src/ui/charts/bar-chart.tsx +135 -0
- package/src/ui/charts/chart-frame.tsx +112 -0
- package/src/ui/charts/chart-types.ts +52 -0
- package/src/ui/charts/chart-utils.ts +158 -0
- package/src/ui/charts/charts.test.tsx +204 -0
- package/src/ui/charts/donut-chart.tsx +111 -0
- package/src/ui/charts/index.ts +27 -0
- package/src/ui/charts/line-chart.tsx +180 -0
- package/src/ui/charts/sparkline.tsx +101 -0
- package/src/ui/hooks/use-is-mobile.ts +28 -0
- package/src/ui/index.ts +25 -0
- package/src/ui/pdf/pdf-preview.tsx +122 -0
- package/src/ui/primitives/preview-device-frame.tsx +205 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Approval primitive — generic frame model + scoped-body payloads
|
|
3
|
-
* (Pillar 3 of the Xema Portals design handoff; HANDOFF §6).
|
|
2
|
+
* Approval primitive — generic frame model + scoped-body payloads.
|
|
4
3
|
*
|
|
5
4
|
* The approval is the runtime face of a kernel policy decision: a
|
|
6
5
|
* capability call returns `allow | deny | needs_approval`
|
|
@@ -9,7 +8,7 @@
|
|
|
9
8
|
* (risk pill + capability ref + obligations + quorum + actions); each
|
|
10
9
|
* biome supplies a typed `scope` body for rich detail — the same
|
|
11
10
|
* generic-frame + biome-body pattern as the in-chat widget registry
|
|
12
|
-
*
|
|
11
|
+
* and the preview-tab kinds.
|
|
13
12
|
*
|
|
14
13
|
* This module REUSES the kernel contracts rather than redefining them:
|
|
15
14
|
* • `CapabilityRiskTier` (low/medium/high/critical) — risk pill + accent
|
|
@@ -24,7 +23,7 @@ import type { CapabilityRiskTier } from '@xemahq/kernel-contracts/capability';
|
|
|
24
23
|
import type { PolicyObligationKind } from '@xemahq/kernel-contracts/policy';
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
* Closed set of biome-supplied scoped-body kinds
|
|
26
|
+
* Closed set of biome-supplied scoped-body kinds. This is the
|
|
28
27
|
* approval analogue of `ChatWidgetKind` — a typed, versioned catalogue of
|
|
29
28
|
* rich bodies that render UNDER the generic frame. The renderer
|
|
30
29
|
* (`ScopedBody`) switches on `kind`; an unknown kind fails SAFE to a
|
|
@@ -165,7 +164,7 @@ export interface ApprovalRequest {
|
|
|
165
164
|
* "allow once" vs "approve & remember" distinction is a UI affordance on
|
|
166
165
|
* top of the same `Approved` decision (the remember flag is carried
|
|
167
166
|
* separately when the backend supports it); both resolve the suspended
|
|
168
|
-
* call. The denied/override-rationale path is
|
|
167
|
+
* call. The denied/override-rationale path is not yet implemented.
|
|
169
168
|
*/
|
|
170
169
|
export enum ApprovalDecision {
|
|
171
170
|
Approved = 'approved',
|
|
@@ -175,14 +174,14 @@ export enum ApprovalDecision {
|
|
|
175
174
|
/**
|
|
176
175
|
* A pending approval as it appears in the cross-portal Approvals Center —
|
|
177
176
|
* the request plus the portal/session context it was raised in. The
|
|
178
|
-
* portal fields are first-class so
|
|
177
|
+
* portal fields are first-class so the Portals surface can batch and group
|
|
179
178
|
* approvals across portals without reshaping this model.
|
|
180
179
|
*/
|
|
181
180
|
export interface ApprovalCenterItem {
|
|
182
181
|
/** Mirrors `request.id`; kept distinct so a Center may track its own row id. */
|
|
183
182
|
readonly id: string;
|
|
184
183
|
readonly request: ApprovalRequest;
|
|
185
|
-
/** Portal the approval was raised in (
|
|
184
|
+
/** Portal the approval was raised in (cross-portal context). */
|
|
186
185
|
readonly portalId?: string;
|
|
187
186
|
readonly portalName?: string;
|
|
188
187
|
/** Portal accent token (`var(--p-*)`) for the row glyph; optional. */
|
|
@@ -194,7 +193,7 @@ export interface ApprovalCenterItem {
|
|
|
194
193
|
|
|
195
194
|
/** True when the tier always demands an explicit Approve (no allow-once). */
|
|
196
195
|
export function isExplicitApprovalTier(tier: CapabilityRiskTier): boolean {
|
|
197
|
-
// `critical` and `high` are explicit-only (
|
|
196
|
+
// `critical` and `high` are explicit-only (critical always
|
|
198
197
|
// explicit; high uses the warning accent and skips allow-once-remember).
|
|
199
198
|
return tier === 'critical' || tier === 'high';
|
|
200
199
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@xemahq/ui-kernel/session-kit` approvals — the generic approval
|
|
3
|
-
* primitive
|
|
3
|
+
* primitive.
|
|
4
4
|
*
|
|
5
5
|
* Generic frame + scoped bodies + sticky bar + Approvals Center. Models on
|
|
6
6
|
* the kernel contracts (`CapabilityRiskTier`, `PolicyObligationKind`) and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Risk-tier → colour expressions for the approval frame's accent rail and
|
|
3
|
-
* the sticky bar
|
|
3
|
+
* the sticky bar. RiskPill owns the PILL styling; this map is
|
|
4
4
|
* the matching surface accent (foreground / soft background / border) the
|
|
5
5
|
* frame and bar paint with. Both read the SAME kernel `CapabilityRiskTier`
|
|
6
6
|
* enum so the pill and the rail can never disagree on a tier's colour.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A single message turn. User turns render a right-aligned soft bubble;
|
|
3
3
|
* agent turns render a thin gutter + role caption + flowing body.
|
|
4
4
|
*
|
|
5
|
-
* The body is rendered by `renderMarkdown` when supplied (
|
|
5
|
+
* The body is rendered by `renderMarkdown` when supplied (host shells wire
|
|
6
6
|
* the host's full `MarkdownRenderer` here) and falls back to the kit's
|
|
7
7
|
* built-in `MD` otherwise — so the kit is self-contained but never
|
|
8
8
|
* forces its tiny renderer on a host that has a richer one.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token/cost meter — input & output token counts plus a running dollar
|
|
3
|
-
* cost. Used both inside the ContextHeader and (in
|
|
3
|
+
* cost. Used both inside the ContextHeader and (in host shells) absolute
|
|
4
4
|
* top-right of the composer.
|
|
5
5
|
*/
|
|
6
6
|
import { SessionSkin } from '../lib/enums';
|
package/src/session-kit/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@xemahq/ui-kernel/session-kit` — the thin, prop-driven generic
|
|
3
3
|
* component substrate for agent-session surfaces and the Portals
|
|
4
|
-
* chrome
|
|
4
|
+
* chrome.
|
|
5
5
|
*
|
|
6
6
|
* Everything here is presentational and icon-agnostic: components take
|
|
7
7
|
* data + host-supplied icon nodes and emit inline-styled markup against
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* subscriptions, no framework imports — so the same kit renders inside
|
|
10
10
|
* the host shell and inside a biome's own surfaces.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Host shells consume these via `import { … } from '@xemahq/ui-kernel/session-kit'`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// Closed-set enums + the authoritative status-dot map.
|
|
@@ -39,6 +39,6 @@ export * from './display/TodoChecklist';
|
|
|
39
39
|
export * from './combobox/Combobox';
|
|
40
40
|
export * from './combobox/use-click-outside';
|
|
41
41
|
|
|
42
|
-
// Approvals primitive
|
|
42
|
+
// Approvals primitive — generic frame + scoped bodies + sticky
|
|
43
43
|
// bar + cross-portal Approvals Center).
|
|
44
44
|
export * from './approvals';
|
|
@@ -17,7 +17,7 @@ export enum ToolStatus {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* The four authoritative transcript status-dot states
|
|
20
|
+
* The four authoritative transcript status-dot states.
|
|
21
21
|
* `Narration` is the hollow dot used by a `say` event — it is not a
|
|
22
22
|
* tool, so it sits outside `ToolStatus`.
|
|
23
23
|
*/
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// AppBrandingProvider — the runtime branding applier
|
|
3
|
+
//
|
|
4
|
+
// Takes a kernel `BrandingConfig` (plus an optional per-sub-tenant override)
|
|
5
|
+
// and APPLIES it at runtime:
|
|
6
|
+
//
|
|
7
|
+
// • writes `primaryColor` → `--app-primary-color` and every `cssTokens`
|
|
8
|
+
// entry → `--<key>` as CSS custom properties on its OWN wrapper element,
|
|
9
|
+
// so they cascade ONLY to the app subtree — never onto
|
|
10
|
+
// `document.documentElement`, so an app's branding cannot leak into the
|
|
11
|
+
// host chrome;
|
|
12
|
+
// • exposes `displayName` / `logoUrl` / `primaryColor` to children through
|
|
13
|
+
// context (`useAppBranding`) for a branded header to render.
|
|
14
|
+
//
|
|
15
|
+
// Every value crosses `sanitize.ts` first: an unsafe CSS value or a
|
|
16
|
+
// non-`http(s)` logo URL is dropped, never written raw.
|
|
17
|
+
//
|
|
18
|
+
// This is the reusable half of Wave 8.4 — a biome imports it from
|
|
19
|
+
// `@xemahq/ui-kernel/ui`, like `useIsMobile` / `Sheet`.
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
|
|
23
|
+
import type { BrandingConfig } from '@xemahq/kernel-contracts/app-runtime';
|
|
24
|
+
|
|
25
|
+
import { cn } from '../cn';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
sanitizeCssCustomPropertyName,
|
|
29
|
+
sanitizeCssValue,
|
|
30
|
+
sanitizeLogoUrl,
|
|
31
|
+
} from './sanitize';
|
|
32
|
+
|
|
33
|
+
/** CSS custom property carrying the app's resolved primary brand color. */
|
|
34
|
+
export const APP_PRIMARY_COLOR_VAR = '--app-primary-color';
|
|
35
|
+
|
|
36
|
+
/** Values a branded header needs — sanitized, safe to render. */
|
|
37
|
+
export interface AppBrandingContextValue {
|
|
38
|
+
readonly displayName: string;
|
|
39
|
+
readonly logoUrl?: string;
|
|
40
|
+
readonly primaryColor?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AppBrandingContext = React.createContext<AppBrandingContextValue | null>(
|
|
44
|
+
null,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolved branding for the nearest enclosing {@link AppBrandingProvider}.
|
|
49
|
+
* Throws when used outside one — a header that assumes branding must not
|
|
50
|
+
* silently render an unbranded surface.
|
|
51
|
+
*/
|
|
52
|
+
export function useAppBranding(): AppBrandingContextValue {
|
|
53
|
+
const ctx = React.useContext(AppBrandingContext);
|
|
54
|
+
if (ctx === null) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'useAppBranding must be used within an <AppBrandingProvider>.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return ctx;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AppBrandingProviderProps {
|
|
63
|
+
/** The app's base branding (from the App definition / release). */
|
|
64
|
+
readonly branding: BrandingConfig;
|
|
65
|
+
/**
|
|
66
|
+
* Per-sub-tenant override, resolved from `AppRoleBinding.tenantId`. Each
|
|
67
|
+
* present field shadows the base; absent fields fall through to `branding`.
|
|
68
|
+
* Optional — apps with no sub-tenant branding pass nothing.
|
|
69
|
+
*/
|
|
70
|
+
readonly tenantBranding?: Partial<BrandingConfig>;
|
|
71
|
+
/** Extra classes for the wrapper element. */
|
|
72
|
+
readonly className?: string;
|
|
73
|
+
readonly children: React.ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Merge base branding with an optional per-sub-tenant override (override wins
|
|
78
|
+
* per field), then sanitize every applied value.
|
|
79
|
+
*/
|
|
80
|
+
function resolveBranding(
|
|
81
|
+
branding: BrandingConfig,
|
|
82
|
+
tenantBranding: Partial<BrandingConfig> | undefined,
|
|
83
|
+
): {
|
|
84
|
+
readonly context: AppBrandingContextValue;
|
|
85
|
+
readonly cssVars: Record<string, string>;
|
|
86
|
+
} {
|
|
87
|
+
const displayName = tenantBranding?.displayName ?? branding.displayName;
|
|
88
|
+
const rawPrimary = tenantBranding?.primaryColor ?? branding.primaryColor;
|
|
89
|
+
const rawLogo = tenantBranding?.logoUrl ?? branding.logoUrl;
|
|
90
|
+
const cssTokens = { ...branding.cssTokens, ...tenantBranding?.cssTokens };
|
|
91
|
+
|
|
92
|
+
const primaryColor =
|
|
93
|
+
rawPrimary !== undefined
|
|
94
|
+
? (sanitizeCssValue(rawPrimary) ?? undefined)
|
|
95
|
+
: undefined;
|
|
96
|
+
const logoUrl = sanitizeLogoUrl(rawLogo);
|
|
97
|
+
|
|
98
|
+
const cssVars: Record<string, string> = {};
|
|
99
|
+
if (primaryColor !== undefined) {
|
|
100
|
+
cssVars[APP_PRIMARY_COLOR_VAR] = primaryColor;
|
|
101
|
+
}
|
|
102
|
+
for (const [rawKey, rawValue] of Object.entries(cssTokens)) {
|
|
103
|
+
const name = sanitizeCssCustomPropertyName(rawKey);
|
|
104
|
+
const value = sanitizeCssValue(rawValue);
|
|
105
|
+
if (name !== null && value !== null) {
|
|
106
|
+
cssVars[name] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
context: { displayName, logoUrl, primaryColor },
|
|
112
|
+
cssVars,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Scopes an app's branding to its subtree. Renders a wrapper element carrying
|
|
118
|
+
* the CSS custom properties inline, so they cascade to the app content and
|
|
119
|
+
* nowhere else. On unmount the element (and its properties) simply disappear —
|
|
120
|
+
* no cleanup, no global mutation.
|
|
121
|
+
*/
|
|
122
|
+
export function AppBrandingProvider({
|
|
123
|
+
branding,
|
|
124
|
+
tenantBranding,
|
|
125
|
+
className,
|
|
126
|
+
children,
|
|
127
|
+
}: AppBrandingProviderProps): React.ReactElement {
|
|
128
|
+
const { context, cssVars } = React.useMemo(
|
|
129
|
+
() => resolveBranding(branding, tenantBranding),
|
|
130
|
+
[branding, tenantBranding],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<AppBrandingContext.Provider value={context}>
|
|
135
|
+
<div
|
|
136
|
+
data-app-branding=""
|
|
137
|
+
className={cn(className)}
|
|
138
|
+
// Custom properties are not part of `React.CSSProperties`; the cast is
|
|
139
|
+
// the standard way to set `--*` vars inline.
|
|
140
|
+
style={cssVars as React.CSSProperties}
|
|
141
|
+
>
|
|
142
|
+
{children}
|
|
143
|
+
</div>
|
|
144
|
+
</AppBrandingContext.Provider>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Branding DOM-boundary sanitizers
|
|
3
|
+
//
|
|
4
|
+
// A `BrandingConfig` is validated server-side (hex/rgb(a) `primaryColor`, a
|
|
5
|
+
// URL `logoUrl`), but the runtime applier writes those values into the live
|
|
6
|
+
// DOM — a CSS custom-property value and an `<img src>`. This module is the
|
|
7
|
+
// fail-closed boundary: a value that could break out of the property it is
|
|
8
|
+
// written into, or a URL scheme that could execute, is DROPPED (returns
|
|
9
|
+
// `null` / `undefined`), never written raw. Pure functions, no React — unit
|
|
10
|
+
// tested directly.
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Characters that could terminate a CSS declaration/value and inject a new
|
|
15
|
+
* declaration or rule (`;`, `{`, `}`), close a surrounding context (`<`, `>`),
|
|
16
|
+
* escape (`\`), or open/close a CSS comment. React writes custom properties
|
|
17
|
+
* through the CSSOM, which already treats the string as a single value — this
|
|
18
|
+
* is defence in depth so a crafted value is refused at the seam.
|
|
19
|
+
*/
|
|
20
|
+
const CSS_VALUE_UNSAFE = /[;{}<>\\]|\/\*|\*\//;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A CSS custom-property name (without the leading `--`): a CSS identifier. A
|
|
24
|
+
* `cssTokens` key becomes `--<key>`, so a key with breakout characters would be
|
|
25
|
+
* a foothold; anything that is not a plain ident is refused.
|
|
26
|
+
*/
|
|
27
|
+
const CSS_CUSTOM_PROPERTY_NAME = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
28
|
+
|
|
29
|
+
/** True when the string contains any ASCII control character (0x00–0x1f, 0x7f). */
|
|
30
|
+
function hasControlChar(value: string): boolean {
|
|
31
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
32
|
+
const code = value.charCodeAt(i);
|
|
33
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize a value destined for a CSS custom property (`--app-primary-color`
|
|
40
|
+
* or a `cssTokens` value). Returns the trimmed value, or `null` to DROP it.
|
|
41
|
+
*/
|
|
42
|
+
export function sanitizeCssValue(raw: string): string | null {
|
|
43
|
+
const value = raw.trim();
|
|
44
|
+
if (value.length === 0) return null;
|
|
45
|
+
if (CSS_VALUE_UNSAFE.test(value)) return null;
|
|
46
|
+
if (hasControlChar(value)) return null;
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize a `cssTokens` KEY into a `--<name>` custom-property name. Returns
|
|
52
|
+
* the full property name (`--<name>`), or `null` to DROP the token.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeCssCustomPropertyName(rawKey: string): string | null {
|
|
55
|
+
const name = rawKey.trim();
|
|
56
|
+
if (!CSS_CUSTOM_PROPERTY_NAME.test(name)) return null;
|
|
57
|
+
return `--${name}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize a logo URL destined for an `<img src>`. Only absolute `http(s)`
|
|
62
|
+
* URLs pass; a `javascript:`, `data:`, or otherwise-schemed / unparseable value
|
|
63
|
+
* is refused (returns `undefined`) so it never reaches `src`.
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizeLogoUrl(raw: string | undefined): string | undefined {
|
|
66
|
+
if (raw === undefined) return undefined;
|
|
67
|
+
let parsed: URL;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return parsed.href;
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive breakpoint token — the single source of truth shared by the
|
|
3
|
+
* JS side (`useIsMobile`) and Tailwind's `md` utility.
|
|
4
|
+
*
|
|
5
|
+
* Tailwind's default `md` breakpoint is `min-width: 768px`. Anything narrower
|
|
6
|
+
* than that is "mobile". Binding the JS constant to that exact value is what
|
|
7
|
+
* lets a component decide table-vs-cards in JS and stay in lockstep with the
|
|
8
|
+
* `md:` classes the same component (or its siblings) use — there is exactly
|
|
9
|
+
* ONE breakpoint system, not two.
|
|
10
|
+
*
|
|
11
|
+
* If the Tailwind `md` screen is ever re-tuned, change it HERE and in the
|
|
12
|
+
* Tailwind theme in the same commit — they are two faces of one token.
|
|
13
|
+
*/
|
|
14
|
+
export const MOBILE_BREAKPOINT = 768;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// <DocumentPreview> — read-only "final document" view for the review step
|
|
3
|
+
//
|
|
4
|
+
// Renders a structured record as the document it will become — grouped by
|
|
5
|
+
// section, one labelled row per field, nested objects/lists expanded inline.
|
|
6
|
+
// Presentational + theme-aware (semantic tokens, light/dark) + responsive.
|
|
7
|
+
// Feed it the SAME schema you gave `<SchemaForm>` so the review mirrors input.
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
import { type ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
import { cn } from '../cn';
|
|
12
|
+
|
|
13
|
+
import { CaptureFieldType, type CaptureFieldSpec, type CaptureSchema, type CaptureSection, type CaptureValues, toSections } from './field-spec';
|
|
14
|
+
import { formatScalar } from './value-format';
|
|
15
|
+
|
|
16
|
+
export interface DocumentPreviewProps {
|
|
17
|
+
readonly fields?: readonly CaptureFieldSpec[];
|
|
18
|
+
readonly sections?: readonly CaptureSection[];
|
|
19
|
+
readonly schema?: CaptureSchema;
|
|
20
|
+
readonly values: CaptureValues;
|
|
21
|
+
readonly title?: string;
|
|
22
|
+
/** Text shown for an unset field (default "—"). */
|
|
23
|
+
readonly emptyText?: string;
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @example
|
|
29
|
+
* <DocumentPreview title="Review" sections={sections} values={values} />
|
|
30
|
+
*/
|
|
31
|
+
export function DocumentPreview({ fields, sections, schema, values, title, emptyText = '—', className }: DocumentPreviewProps) {
|
|
32
|
+
const resolved = toSections(sections ?? (fields ? fields : schema) ?? []);
|
|
33
|
+
return (
|
|
34
|
+
<article className={cn('rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6', className)}>
|
|
35
|
+
{title ? <h2 className="mb-4 text-title font-semibold tracking-tight text-ink">{title}</h2> : null}
|
|
36
|
+
<div className="space-y-6">
|
|
37
|
+
{resolved.map((section) => (
|
|
38
|
+
<section key={section.key}>
|
|
39
|
+
{section.title ? <h3 className="mb-2 border-b border-rule pb-1 text-caption font-semibold uppercase tracking-wider text-ink-3">{section.title}</h3> : null}
|
|
40
|
+
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-[minmax(8rem,12rem)_1fr]">
|
|
41
|
+
{section.fields.map((field) => (
|
|
42
|
+
<FieldRow key={field.key} field={field} value={values[field.key]} emptyText={emptyText} />
|
|
43
|
+
))}
|
|
44
|
+
</dl>
|
|
45
|
+
</section>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</article>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function FieldRow({ field, value, emptyText }: { field: CaptureFieldSpec; value: unknown; emptyText: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<dt className="text-body-1 font-medium text-ink-3 sm:text-right">{field.label}</dt>
|
|
56
|
+
<dd className="min-w-0 text-body-1 text-ink">{renderValue(field, value, emptyText)}</dd>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderValue(field: CaptureFieldSpec, value: unknown, emptyText: string): ReactNode {
|
|
62
|
+
if (field.type === CaptureFieldType.Object) {
|
|
63
|
+
const record = (value && typeof value === 'object' && !Array.isArray(value) ? value : null) as CaptureValues | null;
|
|
64
|
+
if (!record) return <span className="text-ink-3">{emptyText}</span>;
|
|
65
|
+
return (
|
|
66
|
+
<dl className="space-y-1 rounded-md border border-rule bg-paper-elev/40 p-2">
|
|
67
|
+
{(field.fields ?? []).map((child) => (
|
|
68
|
+
<div key={child.key} className="flex flex-wrap gap-2">
|
|
69
|
+
<dt className="text-caption font-medium text-ink-3">{child.label}:</dt>
|
|
70
|
+
<dd className="text-caption text-ink">{renderValue(child, record[child.key], emptyText)}</dd>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</dl>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (field.type === CaptureFieldType.List) {
|
|
77
|
+
const items = (Array.isArray(value) ? value : []) as CaptureValues[];
|
|
78
|
+
if (items.length === 0) return <span className="text-ink-3">{emptyText}</span>;
|
|
79
|
+
return (
|
|
80
|
+
<ol className="space-y-2">
|
|
81
|
+
{items.map((item, index) => (
|
|
82
|
+
<li key={index} className="rounded-md border border-rule bg-paper-elev/40 p-2">
|
|
83
|
+
<p className="mb-1 text-caption font-medium text-ink-3">
|
|
84
|
+
{field.label} #{index + 1}
|
|
85
|
+
</p>
|
|
86
|
+
<dl className="space-y-1">
|
|
87
|
+
{(field.fields ?? []).map((child) => (
|
|
88
|
+
<div key={child.key} className="flex flex-wrap gap-2">
|
|
89
|
+
<dt className="text-caption font-medium text-ink-3">{child.label}:</dt>
|
|
90
|
+
<dd className="text-caption text-ink">{renderValue(child, item[child.key], emptyText)}</dd>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</dl>
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ol>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const text = formatScalar(field, value, emptyText);
|
|
100
|
+
return <span className={cn(text === emptyText && 'text-ink-3')}>{text}</span>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Pure-logic proofs for the CaptureFieldSpec → zod adapter. Runs under the
|
|
2
|
+
// host-web vitest runner (this package carries no vitest dep — see tsconfig
|
|
3
|
+
// exclude), mirroring the repo convention used by i18n-port.test.ts.
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { CaptureFieldType, type CaptureFieldSpec } from './field-spec';
|
|
7
|
+
import { fieldSpecToZod, isEmptyValue } from './field-spec-to-zod';
|
|
8
|
+
|
|
9
|
+
describe('isEmptyValue', () => {
|
|
10
|
+
it('treats undefined/null/empty-string/empty-array as absent', () => {
|
|
11
|
+
expect(isEmptyValue(undefined)).toBe(true);
|
|
12
|
+
expect(isEmptyValue(null)).toBe(true);
|
|
13
|
+
expect(isEmptyValue('')).toBe(true);
|
|
14
|
+
expect(isEmptyValue([])).toBe(true);
|
|
15
|
+
expect(isEmptyValue('x')).toBe(false);
|
|
16
|
+
expect(isEmptyValue(0)).toBe(false);
|
|
17
|
+
expect(isEmptyValue(false)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('fieldSpecToZod', () => {
|
|
22
|
+
const fields: CaptureFieldSpec[] = [
|
|
23
|
+
{ key: 'title', label: 'Title', type: CaptureFieldType.String, required: true },
|
|
24
|
+
{ key: 'count', label: 'Count', type: CaptureFieldType.Number, required: false, min: 0 },
|
|
25
|
+
{ key: 'status', label: 'Status', type: CaptureFieldType.Enum, enumValues: ['open', 'closed'], required: true },
|
|
26
|
+
{
|
|
27
|
+
key: 'tags',
|
|
28
|
+
label: 'Tags',
|
|
29
|
+
type: CaptureFieldType.List,
|
|
30
|
+
min: 1,
|
|
31
|
+
fields: [{ key: 'name', label: 'Name', type: CaptureFieldType.String, required: true }],
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
it('accepts a valid record', () => {
|
|
36
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', count: 3, status: 'open', tags: [{ name: 'a' }] });
|
|
37
|
+
expect(r.success).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects an empty required string', () => {
|
|
41
|
+
const r = fieldSpecToZod(fields).safeParse({ title: '', status: 'open', tags: [{ name: 'a' }] });
|
|
42
|
+
expect(r.success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allows an omitted optional field', () => {
|
|
46
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'closed', tags: [{ name: 'a' }] });
|
|
47
|
+
expect(r.success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects an enum value outside the set', () => {
|
|
51
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'bogus', tags: [{ name: 'a' }] });
|
|
52
|
+
expect(r.success).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('enforces list min and nested required item fields', () => {
|
|
56
|
+
expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [] }).success).toBe(false);
|
|
57
|
+
expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [{ name: '' }] }).success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|