uhc-portal-components-react 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3
4
+
5
+ - Add the brand-neutral `ClinicalSectionSummary` renderer for resolved IPS
6
+ cards. Clinical terminology tokens remain translation keys and are never
7
+ derived into editable or visible names by the component.
8
+
9
+ ## 0.1.2 - 2026-07-29
10
+
11
+ - Added a framework-neutral branding module for official light/dark surface
12
+ artwork with explicit intrinsic dimensions.
13
+ - Replaced synthetic text monograms and forced gradient shapes in the shared
14
+ header and hero with undistorted brand-provided assets.
15
+ - Added regression coverage for official asset paths, surface variants and
16
+ intrinsic proportions.
17
+
18
+ ## 0.1.1 - 2026-07-29
19
+
20
+ - Added publishable shell, entry hero and real-page access chooser modules.
21
+ - Added account/profile slots so personal and professional portals share the
22
+ same UI behavior while host runtimes retain authentication and authorization.
23
+ - Defined the brand-only reuse contract for UHC and VetChain portal families.
24
+ - Ported the corrected UHC Personal light/dark tokens: dark mode changes
25
+ surfaces and text contrast while primary/selected controls retain the exact
26
+ light-mode turquoise-to-blue gradient.
27
+
28
+ ## 0.1.0 - 2026-07-29
29
+
30
+ - Add a brand-neutral account/preferences header with theme, S/M/L density,
31
+ language, guest and sign-out actions.
32
+ - Add a fail-closed protected-profile/PIN gate driven by host-owned
33
+ `ProfileRuntime` state.
34
+ - Add theme tokens configurable for UHC and VetChain portal families.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # UHC Portal Components React
2
+
3
+ Publishable, brand-neutral React components shared by:
4
+
5
+ - UHC Personal
6
+ - UHC Professional
7
+ - VetChain Personal
8
+ - VetChain Professional
9
+
10
+ The package owns visual shell behavior only. Authentication, protected-profile
11
+ storage, PIN verification and actor authorization remain in the host portal
12
+ through `ProfileRuntime`, `LoadedProfileWorkspace` and actor-scoped SDK facades.
13
+
14
+ ```tsx
15
+ import {
16
+ PortalShell,
17
+ PortalHeader,
18
+ PortalEntryHero,
19
+ PortalAccessChooser,
20
+ ProfileProtectionGate,
21
+ } from 'uhc-portal-components-react'
22
+ import 'uhc-portal-components-react/styles.css'
23
+ ```
24
+
25
+ Brand differences are supplied through `PortalBrand`: product name,
26
+ organization, official artwork and CSS variables. Artwork declares its
27
+ light/dark-surface files and intrinsic dimensions; the shell preserves those
28
+ proportions and never synthesizes a text monogram. Components do not import
29
+ Firebase, Next.js or any GDC/UHC SDK runtime.
30
+
31
+ ```tsx
32
+ const brand = {
33
+ family: 'professional',
34
+ productName: 'UNID Professional',
35
+ organizationName: 'Fundación UNID',
36
+ homeHref: '/es',
37
+ mark: {
38
+ lightSurface: { src: '/logos/uhc-logo.svg', alt: 'UHC', width: 100, height: 100 },
39
+ darkSurface: { src: '/logos/uhc-logo.svg', alt: 'UHC', width: 100, height: 100 },
40
+ },
41
+ } satisfies PortalBrand
42
+ ```
43
+
44
+ The package deliberately exposes modules instead of a cloned application:
45
+
46
+ - `PortalShell`: persistent account/preferences chrome and protected content.
47
+ - `PortalEntryHero`: identical product entry composition for every brand.
48
+ - `PortalAccessChooser`: real-page navigation for professional access paths.
49
+ - `ProfileProtectionGate`: fail-closed PIN unlock presentation.
50
+ - `ClinicalSectionSummary`: brand-neutral rendering of already-resolved IPS
51
+ cards for personal and professional portals.
52
+
53
+ Clinical label semantics stay in `gdc-common-utils-ts` / the SDK reader. Hosts
54
+ pass human titles to `ClinicalSectionSummary`: local `CodeableConcept.text`
55
+ when the UI matches the resource language, English `Coding.display` for
56
+ English, or a terminology translation keyed by `system|code` for another
57
+ locale. The terminology token is never a visible or editable title.
58
+
59
+ UHC Personal and VetChain Personal must compose the same personal route
60
+ modules. UHC Professional and VetChain Professional must compose the same
61
+ professional route modules. A brand adapter may change only tokens, logo,
62
+ product names, descriptions and route destinations; it must not fork flow,
63
+ profile or authorization behavior.
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react';
2
+ export type ClinicalSummaryCard = Readonly<{
3
+ key: string;
4
+ title: string;
5
+ resourceType: string;
6
+ date?: string;
7
+ }>;
8
+ export type ClinicalSectionSummaryProps = Readonly<{
9
+ code: string;
10
+ title: string;
11
+ resources: readonly ClinicalSummaryCard[];
12
+ emptyLabel: string;
13
+ resourceTypeLabel?: (resourceType: string) => string;
14
+ actions?: ReactNode;
15
+ }>;
16
+ /**
17
+ * Brand-neutral renderer for one already-resolved IPS section.
18
+ *
19
+ * Hosts must resolve clinical labels with the shared SDK reader before passing
20
+ * cards here. In particular, `title` is human text; `system|code` is a
21
+ * terminology identity/translation key and must never be supplied as a
22
+ * replacement for manually authored text.
23
+ */
24
+ export declare function ClinicalSectionSummary({ code, title, resources, emptyLabel, resourceTypeLabel, actions, }: ClinicalSectionSummaryProps): import("react").JSX.Element;
25
+ //# sourceMappingURL=ClinicalSectionSummary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClinicalSectionSummary.d.ts","sourceRoot":"","sources":["../src/ClinicalSectionSummary.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAC,CAAA;AAEF,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAAC;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,SAAS,mBAAmB,EAAE,CAAA;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,iBAAiB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAA;IACpD,OAAO,CAAC,EAAE,SAAS,CAAA;CACpB,CAAC,CAAA;AAEF;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,IAAI,EACJ,KAAK,EACL,SAAS,EACT,UAAU,EACV,iBAAkD,EAClD,OAAO,GACR,EAAE,2BAA2B,+BAsB7B"}
@@ -0,0 +1,14 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Brand-neutral renderer for one already-resolved IPS section.
4
+ *
5
+ * Hosts must resolve clinical labels with the shared SDK reader before passing
6
+ * cards here. In particular, `title` is human text; `system|code` is a
7
+ * terminology identity/translation key and must never be supplied as a
8
+ * replacement for manually authored text.
9
+ */
10
+ export function ClinicalSectionSummary({ code, title, resources, emptyLabel, resourceTypeLabel = (resourceType) => resourceType, actions, }) {
11
+ return (_jsxs("section", { className: "uhcp-clinical-section", "data-ips-section": code, "aria-labelledby": `uhcp-section-${code}`, children: [_jsxs("header", { children: [_jsxs("h3", { id: `uhcp-section-${code}`, children: [title, " ", _jsxs("span", { children: ["(", resources.length, ")"] })] }), actions] }), resources.length === 0
12
+ ? _jsx("p", { className: "uhcp-clinical-empty", children: emptyLabel })
13
+ : (_jsx("div", { className: "uhcp-clinical-grid", children: resources.map((resource) => (_jsxs("article", { className: "uhcp-clinical-card", "data-resource-type": resource.resourceType, children: [_jsx("p", { className: "uhcp-clinical-title", dir: "auto", children: _jsx("bdi", { children: resource.title }) }), _jsx("p", { className: "uhcp-clinical-type", children: resourceTypeLabel(resource.resourceType) }), resource.date && _jsx("p", { className: "uhcp-clinical-date", children: resource.date })] }, resource.key))) }))] }));
14
+ }
@@ -0,0 +1,13 @@
1
+ import type { PortalBrandArtworkSet } from './contracts.js';
2
+ export type PortalBrandArtworkProps = Readonly<{
3
+ artwork: PortalBrandArtworkSet;
4
+ className?: string;
5
+ }>;
6
+ /**
7
+ * Render official artwork without reconstructing or distorting the brand.
8
+ *
9
+ * Both surface variants remain in the DOM so CSS can follow the effective
10
+ * light/dark theme without coupling this brand module to a framework.
11
+ */
12
+ export declare function PortalBrandArtwork({ artwork, className, }: PortalBrandArtworkProps): import("react").JSX.Element;
13
+ //# sourceMappingURL=PortalBrandArtwork.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PortalBrandArtwork.d.ts","sourceRoot":"","sources":["../src/PortalBrandArtwork.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAE3D,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C,OAAO,EAAE,qBAAqB,CAAA;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAC,CAAA;AAEF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,OAAO,EACP,SAAc,GACf,EAAE,uBAAuB,+BAmBzB"}
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Render official artwork without reconstructing or distorting the brand.
4
+ *
5
+ * Both surface variants remain in the DOM so CSS can follow the effective
6
+ * light/dark theme without coupling this brand module to a framework.
7
+ */
8
+ export function PortalBrandArtwork({ artwork, className = '', }) {
9
+ return (_jsxs("span", { className: `uhcp-brand-artwork ${className}`.trim(), children: [_jsx("img", { className: "uhcp-brand-artwork-light", src: artwork.lightSurface.src, alt: artwork.lightSurface.alt, width: artwork.lightSurface.width, height: artwork.lightSurface.height }), _jsx("img", { className: "uhcp-brand-artwork-dark", src: artwork.darkSurface.src, alt: artwork.darkSurface.alt, width: artwork.darkSurface.width, height: artwork.darkSurface.height })] }));
10
+ }
@@ -0,0 +1,23 @@
1
+ import type { PortalAccessOption, PortalBrand } from './contracts.js';
2
+ export type PortalEntryHeroProps = Readonly<{
3
+ brand: PortalBrand;
4
+ eyebrow: string;
5
+ title: string;
6
+ description: string;
7
+ }>;
8
+ /**
9
+ * Shared product-entry hero. Brand adapters supply only identity and copy.
10
+ */
11
+ export declare function PortalEntryHero({ brand, eyebrow, title, description, }: PortalEntryHeroProps): import("react").JSX.Element;
12
+ export type PortalAccessChooserProps = Readonly<{
13
+ label: string;
14
+ options: readonly PortalAccessOption[];
15
+ }>;
16
+ /**
17
+ * Reusable navigation for product-owned access paths.
18
+ *
19
+ * Every option is a real link so the destination becomes a distinct page and
20
+ * keyboard/screen-reader users receive navigation rather than hidden content.
21
+ */
22
+ export declare function PortalAccessChooser({ label, options }: PortalAccessChooserProps): import("react").JSX.Element;
23
+ //# sourceMappingURL=PortalEntry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PortalEntry.d.ts","sourceRoot":"","sources":["../src/PortalEntry.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAGrE,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,KAAK,EAAE,WAAW,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB,CAAC,CAAA;AAEF;;GAEG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,OAAO,EACP,KAAK,EACL,WAAW,GACZ,EAAE,oBAAoB,+BAgBtB;AAED,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC;IAC9C,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAA;CACvC,CAAC,CAAA;AAEF;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,wBAAwB,+BAa/E"}
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PortalBrandArtwork } from './PortalBrandArtwork.js';
3
+ /**
4
+ * Shared product-entry hero. Brand adapters supply only identity and copy.
5
+ */
6
+ export function PortalEntryHero({ brand, eyebrow, title, description, }) {
7
+ return (_jsxs("section", { className: "uhcp-entry-hero", style: brand.variables, children: [_jsx("div", { className: "uhcp-hero-orbit", "aria-hidden": "true", children: _jsx(PortalBrandArtwork, { artwork: brand.heroArtwork || brand.mark, className: "uhcp-hero-logo" }) }), _jsxs("div", { className: "uhcp-entry-copy", children: [_jsx("div", { className: "uhcp-badge", children: eyebrow }), _jsx("h1", { children: title }), _jsx("p", { children: description })] })] }));
8
+ }
9
+ /**
10
+ * Reusable navigation for product-owned access paths.
11
+ *
12
+ * Every option is a real link so the destination becomes a distinct page and
13
+ * keyboard/screen-reader users receive navigation rather than hidden content.
14
+ */
15
+ export function PortalAccessChooser({ label, options }) {
16
+ return (_jsx("nav", { className: "uhcp-access-grid", "aria-label": label, children: options.map((option, index) => (_jsxs("a", { className: "uhcp-access-card", href: option.href, children: [_jsx("span", { className: "uhcp-access-number", children: String(index + 1).padStart(2, '0') }), _jsx("strong", { children: option.title }), _jsx("span", { children: option.description }), _jsx("small", { children: option.requirement })] }, option.id))) }));
17
+ }
@@ -0,0 +1,22 @@
1
+ import { type PortalBrand, type PortalAccountOption, type PortalLanguageOption, type PortalMenuAction, type PortalShellCopy } from './contracts.js';
2
+ export type PortalHeaderProps = Readonly<{
3
+ brand: PortalBrand;
4
+ copy: PortalShellCopy;
5
+ accountIdentifier?: string | null;
6
+ languageHref?: string;
7
+ languageOptions?: readonly PortalLanguageOption[];
8
+ guestActionHref: string;
9
+ accountOptions?: readonly PortalAccountOption[];
10
+ onSelectAccount?: (id: string) => void;
11
+ menuActions?: readonly PortalMenuAction[];
12
+ onSignOut?: () => void | Promise<void>;
13
+ storageNamespace?: string;
14
+ }>;
15
+ /**
16
+ * Shared account/preferences header.
17
+ *
18
+ * Authentication is deliberately supplied as data and callbacks. The
19
+ * component never imports Firebase and never treats browser state as a role.
20
+ */
21
+ export declare function PortalHeader({ brand, copy, accountIdentifier, languageHref, languageOptions, guestActionHref, accountOptions, onSelectAccount, menuActions, onSignOut, storageNamespace, }: PortalHeaderProps): import("react").JSX.Element;
22
+ //# sourceMappingURL=PortalHeader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PortalHeader.d.ts","sourceRoot":"","sources":["../src/PortalHeader.tsx"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EAGrB,MAAM,gBAAgB,CAAA;AAQvB,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,KAAK,EAAE,WAAW,CAAA;IAClB,IAAI,EAAE,eAAe,CAAA;IACrB,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,eAAe,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACjD,eAAe,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAA;IAC/C,eAAe,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACtC,WAAW,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAA;IACzC,SAAS,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,CAAC,CAAA;AAEF;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,IAAI,EACJ,iBAAiB,EACjB,YAAY,EACZ,eAAoB,EACpB,eAAe,EACf,cAAmB,EACnB,eAAe,EACf,WAAgB,EAChB,SAAS,EACT,gBAA+B,GAChC,EAAE,iBAAiB,+BAmKnB"}
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { portalUiDensities, portalUiThemes, } from './contracts.js';
5
+ import { resolvePortalUiDensity, resolvePortalUiTheme, resolveRenderedPortalTheme, } from './preferences.js';
6
+ import { PortalBrandArtwork } from './PortalBrandArtwork.js';
7
+ /**
8
+ * Shared account/preferences header.
9
+ *
10
+ * Authentication is deliberately supplied as data and callbacks. The
11
+ * component never imports Firebase and never treats browser state as a role.
12
+ */
13
+ export function PortalHeader({ brand, copy, accountIdentifier, languageHref, languageOptions = [], guestActionHref, accountOptions = [], onSelectAccount, menuActions = [], onSignOut, storageNamespace = 'uhc-portal', }) {
14
+ const [open, setOpen] = useState(false);
15
+ const [density, setDensity] = useState('medium');
16
+ const [theme, setTheme] = useState('system');
17
+ const ref = useRef(null);
18
+ const densityKey = `${storageNamespace}.density`;
19
+ const themeKey = `${storageNamespace}.theme`;
20
+ useEffect(() => {
21
+ const savedDensity = resolvePortalUiDensity(localStorage.getItem(densityKey));
22
+ const savedTheme = resolvePortalUiTheme(localStorage.getItem(themeKey));
23
+ setDensity(savedDensity);
24
+ setTheme(savedTheme);
25
+ document.documentElement.dataset.density = savedDensity;
26
+ }, [densityKey, themeKey]);
27
+ useEffect(() => {
28
+ const media = matchMedia('(prefers-color-scheme: dark)');
29
+ const apply = () => {
30
+ const rendered = resolveRenderedPortalTheme(theme, media.matches);
31
+ document.documentElement.dataset.theme = rendered;
32
+ document.documentElement.style.colorScheme = rendered;
33
+ };
34
+ apply();
35
+ media.addEventListener('change', apply);
36
+ return () => media.removeEventListener('change', apply);
37
+ }, [theme]);
38
+ useEffect(() => {
39
+ const closeOutside = (event) => {
40
+ if (!ref.current?.contains(event.target))
41
+ setOpen(false);
42
+ };
43
+ const closeEscape = (event) => {
44
+ if (event.key === 'Escape')
45
+ setOpen(false);
46
+ };
47
+ document.addEventListener('mousedown', closeOutside);
48
+ window.addEventListener('keydown', closeEscape);
49
+ return () => {
50
+ document.removeEventListener('mousedown', closeOutside);
51
+ window.removeEventListener('keydown', closeEscape);
52
+ };
53
+ }, []);
54
+ function changeDensity(value) {
55
+ setDensity(value);
56
+ localStorage.setItem(densityKey, value);
57
+ document.documentElement.dataset.density = value;
58
+ }
59
+ function changeTheme(value) {
60
+ setTheme(value);
61
+ localStorage.setItem(themeKey, value);
62
+ }
63
+ return (_jsxs("header", { className: "uhcp-header", style: brand.variables, children: [_jsxs("a", { className: "uhcp-brand", href: brand.homeHref, "aria-label": copy.home, children: [_jsx(PortalBrandArtwork, { artwork: brand.mark, className: "uhcp-brand-mark" }), _jsxs("span", { children: [_jsx("strong", { children: brand.productName }), _jsx("small", { children: brand.organizationName })] })] }), _jsxs("div", { className: "uhcp-account-menu", ref: ref, children: [_jsxs("button", { type: "button", className: "uhcp-trigger", onClick: () => setOpen((value) => !value), "aria-label": copy.accountAndPreferences, "aria-expanded": open, children: [_jsx(PersonIcon, {}), accountIdentifier && _jsx("span", { className: "uhcp-account-id", children: accountIdentifier }), _jsx("span", { "aria-hidden": "true", children: "\u2304" })] }), open && (_jsxs("section", { className: "uhcp-menu", "aria-label": copy.accountAndPreferences, children: [_jsxs("div", { className: "uhcp-account-state", children: [_jsx("strong", { children: copy.accountAndPreferences }), _jsx("small", { children: accountIdentifier || copy.notSignedIn })] }), accountOptions.length > 0 && (_jsx("div", { className: "uhcp-account-options", children: accountOptions.map((option) => (_jsxs("button", { type: "button", disabled: option.disabled, "aria-pressed": Boolean(option.selected), onClick: () => {
64
+ onSelectAccount?.(option.id);
65
+ setOpen(false);
66
+ }, children: [_jsx("span", { children: option.label }), option.description && _jsx("small", { children: option.description })] }, option.id))) })), _jsx(PreferenceGroup, { label: copy.appearance, children: portalUiThemes.map((value) => (_jsxs("button", { type: "button", "aria-pressed": theme === value, onClick: () => changeTheme(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'light' ? '☀' : value === 'dark' ? '☾' : '◫' }), copy.themes[value]] }, value))) }), _jsx(PreferenceGroup, { label: copy.textSize, children: portalUiDensities.map((value) => (_jsx("button", { type: "button", "aria-pressed": density === value, onClick: () => changeDensity(value), children: value === 'small' ? 'S' : value === 'medium' ? 'M' : 'L' }, value))) }), _jsxs("div", { className: "uhcp-menu-actions", children: [languageOptions.length > 0
67
+ ? languageOptions.map((option) => (_jsxs("a", { href: option.href, hrefLang: option.id, "aria-current": option.current ? 'page' : undefined, onClick: () => setOpen(false), children: ["\u25CE ", option.label] }, option.id)))
68
+ : languageHref && _jsxs("a", { href: languageHref, onClick: () => setOpen(false), children: ["\u25CE ", copy.language] }), menuActions.map((action) => action.href
69
+ ? (_jsx("a", { href: action.href, onClick: () => setOpen(false), children: action.label }, action.id))
70
+ : (_jsx("button", { type: "button", onClick: () => {
71
+ setOpen(false);
72
+ void action.onSelect?.();
73
+ }, children: action.label }, action.id))), accountIdentifier && onSignOut
74
+ ? _jsxs("button", { type: "button", onClick: () => { setOpen(false); void onSignOut(); }, children: ["\u21AA ", copy.signOut] })
75
+ : _jsxs("a", { href: guestActionHref, onClick: () => setOpen(false), children: [_jsx(PersonIcon, {}), " ", copy.chooseAccess] })] })] }))] })] }));
76
+ }
77
+ function PreferenceGroup({ label, children }) {
78
+ return _jsxs("fieldset", { className: "uhcp-preferences", children: [_jsx("legend", { children: label }), _jsx("div", { children: children })] });
79
+ }
80
+ function PersonIcon() {
81
+ return (_jsxs("svg", { className: "uhcp-person-icon", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: [_jsx("path", { d: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", stroke: "currentColor", strokeWidth: "1.8" }), _jsx("path", { d: "M4.5 21a7.5 7.5 0 0 1 15 0", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round" })] }));
82
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type PortalHeaderProps } from './PortalHeader.js';
3
+ import { type ProfileProtectionGateProps } from './ProfileProtectionGate.js';
4
+ export type PortalShellProps = Readonly<{
5
+ header: PortalHeaderProps;
6
+ profileProtection?: Omit<ProfileProtectionGateProps, 'children'>;
7
+ children: ReactNode;
8
+ backAction?: Readonly<{
9
+ label: string;
10
+ href: string;
11
+ }>;
12
+ assistantAction?: Readonly<{
13
+ label: string;
14
+ onSelect: () => void;
15
+ }>;
16
+ }>;
17
+ /**
18
+ * Brand-neutral portal chrome shared by personal and professional products.
19
+ *
20
+ * Ordered flow:
21
+ * 1. Keep account and device preferences available in the persistent header.
22
+ * 2. Fail closed behind the host-supplied protected-profile state.
23
+ * 3. Render product-owned content only after the host reports an unlocked
24
+ * profile, or immediately when that route explicitly needs no profile.
25
+ *
26
+ * Authentication, authorization, PIN verification and actor selection remain
27
+ * host responsibilities. Product brands may change tokens and copy only.
28
+ */
29
+ export declare function PortalShell({ header, profileProtection, children, backAction, assistantAction, }: PortalShellProps): import("react").JSX.Element;
30
+ //# sourceMappingURL=PortalShell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PortalShell.d.ts","sourceRoot":"","sources":["../src/PortalShell.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,4BAA4B,CAAA;AAEnC,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,MAAM,EAAE,iBAAiB,CAAA;IACzB,iBAAiB,CAAC,EAAE,IAAI,CAAC,0BAA0B,EAAE,UAAU,CAAC,CAAA;IAChE,QAAQ,EAAE,SAAS,CAAA;IACnB,UAAU,CAAC,EAAE,QAAQ,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACtD,eAAe,CAAC,EAAE,QAAQ,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC,CAAA;CACpE,CAAC,CAAA;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,iBAAiB,EACjB,QAAQ,EACR,UAAU,EACV,eAAe,GAChB,EAAE,gBAAgB,+BA2BlB"}
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PortalHeader, } from './PortalHeader.js';
3
+ import { ProfileProtectionGate, } from './ProfileProtectionGate.js';
4
+ /**
5
+ * Brand-neutral portal chrome shared by personal and professional products.
6
+ *
7
+ * Ordered flow:
8
+ * 1. Keep account and device preferences available in the persistent header.
9
+ * 2. Fail closed behind the host-supplied protected-profile state.
10
+ * 3. Render product-owned content only after the host reports an unlocked
11
+ * profile, or immediately when that route explicitly needs no profile.
12
+ *
13
+ * Authentication, authorization, PIN verification and actor selection remain
14
+ * host responsibilities. Product brands may change tokens and copy only.
15
+ */
16
+ export function PortalShell({ header, profileProtection, children, backAction, assistantAction, }) {
17
+ const content = profileProtection
18
+ ? _jsx(ProfileProtectionGate, { ...profileProtection, children: children })
19
+ : children;
20
+ return (_jsxs("div", { className: "uhcp-shell", "data-portal-family": header.brand.family, style: header.brand.variables, children: [_jsx(PortalHeader, { ...header }), _jsx("main", { className: "uhcp-main", children: content }), backAction && (_jsx("a", { className: "uhcp-floating-action uhcp-floating-back", href: backAction.href, "aria-label": backAction.label, children: _jsx("span", { "aria-hidden": "true", children: "\u2190" }) })), assistantAction && (_jsxs("button", { className: "uhcp-floating-action uhcp-floating-assistant", type: "button", onClick: assistantAction.onSelect, "aria-label": assistantAction.label, children: [_jsx("span", { "aria-hidden": "true", children: "\u2726" }), _jsx("span", { children: assistantAction.label })] }))] }));
21
+ }
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { ProfileProtectionCopy, ProfileProtectionState, ProfileUnlockResult } from './contracts.js';
3
+ export type ProfileProtectionGateProps = Readonly<{
4
+ state: ProfileProtectionState;
5
+ copy: ProfileProtectionCopy;
6
+ onUnlock?: (pin: string) => ProfileUnlockResult | Promise<ProfileUnlockResult>;
7
+ onRetry?: () => void;
8
+ children: ReactNode;
9
+ }>;
10
+ /**
11
+ * Fail-closed protected-profile gate shared by portal products.
12
+ *
13
+ * The host owns ProfileRuntime and supplies authoritative state/callbacks.
14
+ * Children are never rendered while protection is unresolved or locked.
15
+ */
16
+ export declare function ProfileProtectionGate({ state, copy, onUnlock, onRetry, children, }: ProfileProtectionGateProps): import("react").JSX.Element;
17
+ //# sourceMappingURL=ProfileProtectionGate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProfileProtectionGate.d.ts","sourceRoot":"","sources":["../src/ProfileProtectionGate.tsx"],"names":[],"mappings":"AAEA,OAAO,EAA+C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AACnF,OAAO,KAAK,EACV,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACpB,MAAM,gBAAgB,CAAA;AAEvB,MAAM,MAAM,0BAA0B,GAAG,QAAQ,CAAC;IAChD,KAAK,EAAE,sBAAsB,CAAA;IAC7B,IAAI,EAAE,qBAAqB,CAAA;IAC3B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAA;IAC9E,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,QAAQ,EAAE,SAAS,CAAA;CACpB,CAAC,CAAA;AAEF;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,KAAK,EACL,IAAI,EACJ,QAAQ,EACR,OAAO,EACP,QAAQ,GACT,EAAE,0BAA0B,+BAiF5B"}
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ /**
5
+ * Fail-closed protected-profile gate shared by portal products.
6
+ *
7
+ * The host owns ProfileRuntime and supplies authoritative state/callbacks.
8
+ * Children are never rendered while protection is unresolved or locked.
9
+ */
10
+ export function ProfileProtectionGate({ state, copy, onUnlock, onRetry, children, }) {
11
+ const [pin, setPin] = useState('');
12
+ const [error, setError] = useState('');
13
+ const [busy, setBusy] = useState(false);
14
+ const input = useRef(null);
15
+ useEffect(() => {
16
+ setPin('');
17
+ setError('');
18
+ if (state === 'locked')
19
+ input.current?.focus();
20
+ }, [state]);
21
+ async function unlock(event) {
22
+ event.preventDefault();
23
+ if (pin.length < 6) {
24
+ setError(copy.pinRequired);
25
+ return;
26
+ }
27
+ if (!onUnlock) {
28
+ setError(copy.unlockUnavailable);
29
+ return;
30
+ }
31
+ setBusy(true);
32
+ try {
33
+ const result = await onUnlock(pin);
34
+ if (result === 'unlocked') {
35
+ setPin('');
36
+ setError('');
37
+ return;
38
+ }
39
+ setError(result === 'invalid-pin'
40
+ ? copy.invalidPin
41
+ : result === 'temporarily-locked'
42
+ ? copy.temporarilyLocked
43
+ : copy.unlockUnavailable);
44
+ }
45
+ finally {
46
+ setBusy(false);
47
+ }
48
+ }
49
+ if (state === 'not-required')
50
+ return _jsx(_Fragment, { children: children });
51
+ const title = state === 'checking'
52
+ ? copy.checkingTitle
53
+ : state === 'setup-required'
54
+ ? copy.setupTitle
55
+ : state === 'unavailable'
56
+ ? copy.unavailableTitle
57
+ : copy.unlockTitle;
58
+ const body = state === 'checking'
59
+ ? copy.checkingBody
60
+ : state === 'setup-required'
61
+ ? copy.setupBody
62
+ : state === 'unavailable'
63
+ ? copy.unavailableBody
64
+ : copy.unlockBody;
65
+ return (_jsxs("section", { className: "uhcp-profile-gate", "aria-live": "polite", children: [_jsx("span", { className: "uhcp-lock", "aria-hidden": "true", children: "\u233E" }), _jsx("h1", { children: title }), _jsx("p", { children: body }), state === 'locked' && (_jsxs("form", { onSubmit: unlock, children: [_jsxs("label", { children: [copy.pinLabel, _jsx("input", { ref: input, type: "password", minLength: 6, autoComplete: "current-password", value: pin, onChange: (event) => setPin(event.target.value) })] }), error && _jsx("p", { role: "alert", className: "uhcp-error", children: error }), _jsx("button", { type: "submit", disabled: busy, children: copy.unlock })] })), state === 'unavailable' && onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] }));
66
+ }
@@ -0,0 +1,90 @@
1
+ import type { CSSProperties } from 'react';
2
+ export declare const portalUiDensities: readonly ["small", "medium", "large"];
3
+ export type PortalUiDensity = (typeof portalUiDensities)[number];
4
+ export declare const portalUiThemes: readonly ["light", "dark", "system"];
5
+ export type PortalUiTheme = (typeof portalUiThemes)[number];
6
+ export type PortalBrandAsset = Readonly<{
7
+ src: string;
8
+ alt: string;
9
+ width: number;
10
+ height: number;
11
+ }>;
12
+ /**
13
+ * Official artwork selected from the surface where it is rendered.
14
+ *
15
+ * A portal must preserve the asset's intrinsic proportions. The shared shell
16
+ * never reconstructs a brand from text or forces artwork into a generic shape.
17
+ */
18
+ export type PortalBrandArtworkSet = Readonly<{
19
+ darkSurface: PortalBrandAsset;
20
+ lightSurface: PortalBrandAsset;
21
+ }>;
22
+ export type PortalBrand = Readonly<{
23
+ /** Product family is presentation metadata, never an authorization input. */
24
+ family: 'personal' | 'professional';
25
+ productName: string;
26
+ organizationName: string;
27
+ homeHref: string;
28
+ /** Compact official mark for navigation chrome. */
29
+ mark: PortalBrandArtworkSet;
30
+ /** Optional official hero artwork; the compact mark is used when omitted. */
31
+ heroArtwork?: PortalBrandArtworkSet;
32
+ variables?: CSSProperties;
33
+ }>;
34
+ export type PortalLanguageOption = Readonly<{
35
+ id: string;
36
+ label: string;
37
+ href: string;
38
+ current?: boolean;
39
+ }>;
40
+ export type PortalAccountOption = Readonly<{
41
+ id: string;
42
+ label: string;
43
+ description?: string;
44
+ selected?: boolean;
45
+ disabled?: boolean;
46
+ }>;
47
+ export type PortalMenuAction = Readonly<{
48
+ id: string;
49
+ label: string;
50
+ href?: string;
51
+ onSelect?: () => void | Promise<void>;
52
+ }>;
53
+ export type PortalAccessOption = Readonly<{
54
+ id: string;
55
+ href: string;
56
+ title: string;
57
+ description: string;
58
+ requirement: string;
59
+ }>;
60
+ export type PortalShellCopy = Readonly<{
61
+ home: string;
62
+ accountAndPreferences: string;
63
+ notSignedIn: string;
64
+ appearance: string;
65
+ textSize: string;
66
+ themes: Record<PortalUiTheme, string>;
67
+ language: string;
68
+ chooseAccess: string;
69
+ signOut: string;
70
+ }>;
71
+ export type ProfileProtectionState = 'not-required' | 'checking' | 'setup-required' | 'locked' | 'unavailable';
72
+ export type ProfileUnlockResult = 'unlocked' | 'invalid-pin' | 'temporarily-locked' | 'unavailable';
73
+ export type ProfileProtectionCopy = Readonly<{
74
+ checkingTitle: string;
75
+ checkingBody: string;
76
+ setupTitle: string;
77
+ setupBody: string;
78
+ unavailableTitle: string;
79
+ unavailableBody: string;
80
+ unlockTitle: string;
81
+ unlockBody: string;
82
+ pinLabel: string;
83
+ pinRequired: string;
84
+ invalidPin: string;
85
+ temporarilyLocked: string;
86
+ unlockUnavailable: string;
87
+ unlock: string;
88
+ retry: string;
89
+ }>;
90
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export const portalUiDensities = ['small', 'medium', 'large'];
2
+ export const portalUiThemes = ['light', 'dark', 'system'];
@@ -0,0 +1,9 @@
1
+ export * from './contracts.js';
2
+ export * from './preferences.js';
3
+ export * from './PortalBrandArtwork.js';
4
+ export * from './PortalHeader.js';
5
+ export * from './PortalShell.js';
6
+ export * from './PortalEntry.js';
7
+ export * from './ProfileProtectionGate.js';
8
+ export * from './ClinicalSectionSummary.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,kBAAkB,CAAA;AAChC,cAAc,yBAAyB,CAAA;AACvC,cAAc,mBAAmB,CAAA;AACjC,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,4BAA4B,CAAA;AAC1C,cAAc,6BAA6B,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from './contracts.js';
2
+ export * from './preferences.js';
3
+ export * from './PortalBrandArtwork.js';
4
+ export * from './PortalHeader.js';
5
+ export * from './PortalShell.js';
6
+ export * from './PortalEntry.js';
7
+ export * from './ProfileProtectionGate.js';
8
+ export * from './ClinicalSectionSummary.js';
@@ -0,0 +1,5 @@
1
+ import { type PortalUiDensity, type PortalUiTheme } from './contracts.js';
2
+ export declare function resolvePortalUiDensity(value: string | null): PortalUiDensity;
3
+ export declare function resolvePortalUiTheme(value: string | null): PortalUiTheme;
4
+ export declare function resolveRenderedPortalTheme(theme: PortalUiTheme, prefersDark: boolean): 'light' | 'dark';
5
+ //# sourceMappingURL=preferences.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preferences.d.ts","sourceRoot":"","sources":["../src/preferences.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,aAAa,EACnB,MAAM,gBAAgB,CAAA;AAEvB,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,eAAe,CAI5E;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,aAAa,CAIxE;AAED,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,aAAa,EACpB,WAAW,EAAE,OAAO,GACnB,OAAO,GAAG,MAAM,CAElB"}
@@ -0,0 +1,14 @@
1
+ import { portalUiDensities, portalUiThemes, } from './contracts.js';
2
+ export function resolvePortalUiDensity(value) {
3
+ return portalUiDensities.includes(value)
4
+ ? value
5
+ : 'medium';
6
+ }
7
+ export function resolvePortalUiTheme(value) {
8
+ return portalUiThemes.includes(value)
9
+ ? value
10
+ : 'system';
11
+ }
12
+ export function resolveRenderedPortalTheme(theme, prefersDark) {
13
+ return theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
14
+ }
@@ -0,0 +1,234 @@
1
+ :root {
2
+ --uhcp-surface: hsl(210 28% 96%);
3
+ --uhcp-surface-muted: hsl(210 22% 91%);
4
+ --uhcp-surface-accent: hsl(188 45% 90%);
5
+ --uhcp-panel: hsl(0 0% 100%);
6
+ --uhcp-panel-muted: hsl(210 26% 94%);
7
+ --uhcp-text: hsl(218 39% 15%);
8
+ --uhcp-muted: hsl(216 15% 38%);
9
+ --uhcp-border: hsl(212 21% 80%);
10
+ --uhcp-turquoise: hsl(184 74% 34%);
11
+ --uhcp-emerald: hsl(157 67% 31%);
12
+ --uhcp-attention: hsl(29 82% 61%);
13
+ /* Intentionally invariant across light/dark, matching UHC Personal. */
14
+ --uhcp-action-start: hsl(184 74% 34% / .96);
15
+ --uhcp-action-end: hsl(214 70% 42% / .9);
16
+ }
17
+ html[data-density='small'] { font-size: 15px; }
18
+ html[data-density='medium'] { font-size: 16px; }
19
+ html[data-density='large'] { font-size: 18px; }
20
+ html[data-theme='dark'] {
21
+ --uhcp-surface: hsl(218 46% 9%);
22
+ --uhcp-surface-muted: hsl(218 37% 13%);
23
+ --uhcp-surface-accent: hsl(188 42% 16%);
24
+ --uhcp-panel: hsl(218 38% 13%);
25
+ --uhcp-panel-muted: hsl(218 34% 17%);
26
+ --uhcp-text: hsl(196 28% 94%);
27
+ --uhcp-muted: hsl(204 15% 70%);
28
+ --uhcp-border: hsl(210 24% 25%);
29
+ --uhcp-turquoise: hsl(185 70% 51%);
30
+ --uhcp-emerald: hsl(153 59% 48%);
31
+ --uhcp-attention: hsl(30 76% 65%);
32
+ }
33
+ .uhcp-header {
34
+ align-items: center;
35
+ backdrop-filter: blur(18px);
36
+ background: color-mix(in srgb, var(--uhcp-panel) 92%, transparent);
37
+ border-bottom: 1px solid var(--uhcp-border);
38
+ display: flex;
39
+ justify-content: space-between;
40
+ min-height: 4.5rem;
41
+ padding: .75rem max(1rem, calc((100vw - 1160px) / 2));
42
+ position: sticky;
43
+ top: 0;
44
+ z-index: 40;
45
+ }
46
+ .uhcp-brand { align-items: center; color: var(--uhcp-text); display: flex; gap: .75rem; text-decoration: none; }
47
+ .uhcp-brand > span:last-child { display: grid; line-height: 1.15; }
48
+ .uhcp-brand small { color: var(--uhcp-muted); }
49
+ .uhcp-brand-mark {
50
+ align-items: center;
51
+ display: inline-flex;
52
+ height: 2.75rem;
53
+ justify-content: center;
54
+ width: 2.75rem;
55
+ }
56
+ .uhcp-brand-artwork { display: inline-flex; }
57
+ .uhcp-brand-artwork img {
58
+ display: block;
59
+ height: 100%;
60
+ max-height: 100%;
61
+ max-width: 100%;
62
+ object-fit: contain;
63
+ width: 100%;
64
+ }
65
+ .uhcp-brand-artwork-dark { display: none !important; }
66
+ html[data-theme='dark'] .uhcp-brand-artwork-light { display: none !important; }
67
+ html[data-theme='dark'] .uhcp-brand-artwork-dark { display: block !important; }
68
+ .uhcp-account-menu { position: relative; }
69
+ .uhcp-trigger {
70
+ align-items: center;
71
+ background: var(--uhcp-panel);
72
+ border: 1px solid var(--uhcp-border);
73
+ border-radius: 999px;
74
+ color: var(--uhcp-text);
75
+ cursor: pointer;
76
+ display: flex;
77
+ gap: .5rem;
78
+ max-width: min(19rem, 48vw);
79
+ padding: .7rem .85rem;
80
+ }
81
+ .uhcp-person-icon { height: 1.25rem; min-width: 1.25rem; width: 1.25rem; }
82
+ .uhcp-account-id { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83
+ .uhcp-menu {
84
+ background: var(--uhcp-panel);
85
+ border: 1px solid var(--uhcp-border);
86
+ border-radius: 1.5rem;
87
+ box-shadow: 0 20px 55px rgb(0 0 0 / 18%);
88
+ max-height: calc(100vh - 6rem);
89
+ overflow-y: auto;
90
+ padding: 1rem;
91
+ position: absolute;
92
+ right: 0;
93
+ top: 3.5rem;
94
+ width: min(23rem, calc(100vw - 2rem));
95
+ }
96
+ .uhcp-account-state { border-bottom: 1px solid var(--uhcp-border); color: var(--uhcp-text); display: grid; gap: .25rem; padding-bottom: 1rem; }
97
+ .uhcp-account-state small { color: var(--uhcp-muted); overflow: hidden; text-overflow: ellipsis; }
98
+ .uhcp-account-options { border-bottom: 1px solid var(--uhcp-border); display: grid; gap: .25rem; padding: .75rem 0; }
99
+ .uhcp-account-options button {
100
+ background: transparent;
101
+ border: 1px solid transparent;
102
+ border-radius: .75rem;
103
+ color: var(--uhcp-text);
104
+ cursor: pointer;
105
+ display: grid;
106
+ font: inherit;
107
+ gap: .2rem;
108
+ padding: .7rem;
109
+ text-align: left;
110
+ }
111
+ .uhcp-account-options button[aria-pressed='true'] { background: var(--uhcp-surface); border-color: var(--uhcp-accent); }
112
+ .uhcp-account-options button:disabled { cursor: not-allowed; opacity: .55; }
113
+ .uhcp-account-options small { color: var(--uhcp-muted); }
114
+ .uhcp-preferences { border: 0; margin: 1rem 0 0; padding: 0; }
115
+ .uhcp-preferences legend { color: var(--uhcp-text); font-weight: 800; }
116
+ .uhcp-preferences > div { background: var(--uhcp-surface-muted); border-radius: 1rem; display: grid; gap: .25rem; grid-template-columns: repeat(3, 1fr); margin-top: .5rem; padding: .25rem; }
117
+ .uhcp-preferences button {
118
+ background: transparent;
119
+ border: 0;
120
+ border-radius: .75rem;
121
+ color: var(--uhcp-muted);
122
+ cursor: pointer;
123
+ display: grid;
124
+ font: inherit;
125
+ font-size: .72rem;
126
+ gap: .2rem;
127
+ padding: .55rem .25rem;
128
+ }
129
+ .uhcp-preferences button[aria-pressed='true'] { background: linear-gradient(135deg, var(--uhcp-action-start), var(--uhcp-action-end)); color: #fff; }
130
+ .uhcp-menu-actions { border-top: 1px solid var(--uhcp-border); display: grid; gap: .2rem; margin-top: 1rem; padding-top: .75rem; }
131
+ .uhcp-menu-actions a, .uhcp-menu-actions button {
132
+ align-items: center;
133
+ background: transparent;
134
+ border: 0;
135
+ border-radius: .75rem;
136
+ color: var(--uhcp-text);
137
+ cursor: pointer;
138
+ display: flex;
139
+ font: inherit;
140
+ font-weight: 700;
141
+ gap: .7rem;
142
+ padding: .7rem;
143
+ text-align: left;
144
+ text-decoration: none;
145
+ width: 100%;
146
+ }
147
+ .uhcp-menu-actions a:hover, .uhcp-menu-actions button:hover { background: var(--uhcp-surface-muted); color: var(--uhcp-turquoise); }
148
+ .uhcp-profile-gate {
149
+ background: var(--uhcp-panel);
150
+ border: 1px solid var(--uhcp-border);
151
+ border-radius: 2rem;
152
+ color: var(--uhcp-text);
153
+ margin: 2rem auto;
154
+ max-width: 36rem;
155
+ padding: 2rem;
156
+ text-align: center;
157
+ }
158
+ .uhcp-profile-gate > p { color: var(--uhcp-muted); line-height: 1.6; }
159
+ .uhcp-lock { color: var(--uhcp-turquoise); font-size: 2.5rem; }
160
+ .uhcp-profile-gate form { display: grid; gap: 1rem; margin-top: 1.5rem; text-align: left; }
161
+ .uhcp-profile-gate label { color: var(--uhcp-muted); display: grid; gap: .45rem; }
162
+ .uhcp-profile-gate input { background: var(--uhcp-panel); border: 1px solid var(--uhcp-border); border-radius: .75rem; color: var(--uhcp-text); padding: .8rem; }
163
+ .uhcp-profile-gate button { background: linear-gradient(135deg, var(--uhcp-action-start), var(--uhcp-action-end)); border: 0; border-radius: .75rem; color: #fff; cursor: pointer; font: inherit; font-weight: 800; padding: .8rem 1rem; }
164
+ .uhcp-error { color: #c64747 !important; }
165
+ .uhcp-shell { background: var(--uhcp-surface); color: var(--uhcp-text); min-height: 100vh; }
166
+ .uhcp-main { min-height: calc(100vh - 4.5rem); }
167
+ .uhcp-clinical-section {
168
+ background: var(--uhcp-panel);
169
+ border: 1px solid var(--uhcp-border);
170
+ border-radius: 2rem;
171
+ color: var(--uhcp-text);
172
+ padding: 1.25rem;
173
+ }
174
+ .uhcp-clinical-section > header { align-items: center; display: flex; gap: 1rem; justify-content: space-between; }
175
+ .uhcp-clinical-section h3 { font-size: 1.125rem; margin: 0; }
176
+ .uhcp-clinical-section h3 span { color: var(--uhcp-muted); }
177
+ .uhcp-clinical-grid { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); margin-top: 1rem; }
178
+ .uhcp-clinical-card { background: var(--uhcp-panel-muted); border-radius: 1rem; padding: 1rem; }
179
+ .uhcp-clinical-title { font-weight: 800; margin: 0; }
180
+ .uhcp-clinical-type { color: var(--uhcp-muted); font-size: .72rem; font-weight: 800; letter-spacing: .06em; margin: .35rem 0 0; text-transform: uppercase; }
181
+ .uhcp-clinical-date, .uhcp-clinical-empty { color: var(--uhcp-muted); margin: .35rem 0 0; }
182
+ .uhcp-entry-hero {
183
+ align-items: center;
184
+ background:
185
+ radial-gradient(circle at 15% 25%, color-mix(in srgb, var(--uhcp-turquoise) 25%, transparent), transparent 35%),
186
+ linear-gradient(135deg, color-mix(in srgb, var(--uhcp-action-end) 14%, var(--uhcp-surface)), var(--uhcp-surface));
187
+ display: grid;
188
+ gap: clamp(2rem, 7vw, 6rem);
189
+ grid-template-columns: minmax(12rem, .65fr) minmax(18rem, 1.35fr);
190
+ min-height: 26rem;
191
+ padding: clamp(2rem, 7vw, 6rem) max(1rem, calc((100vw - 1160px) / 2));
192
+ }
193
+ .uhcp-hero-orbit { border: 1px solid var(--uhcp-border); border-radius: 50%; display: grid; justify-self: center; padding: clamp(2rem, 6vw, 4.5rem); }
194
+ .uhcp-hero-logo {
195
+ align-items: center;
196
+ display: flex;
197
+ height: clamp(8rem, 18vw, 12rem);
198
+ justify-content: center;
199
+ width: clamp(8rem, 18vw, 12rem);
200
+ }
201
+ .uhcp-entry-copy { max-width: 48rem; }
202
+ .uhcp-entry-copy h1 { font-size: clamp(2.25rem, 5vw, 4.75rem); letter-spacing: -.045em; line-height: .98; margin: 1rem 0; }
203
+ .uhcp-entry-copy p { color: var(--uhcp-muted); font-size: clamp(1rem, 2vw, 1.25rem); line-height: 1.7; }
204
+ .uhcp-badge { color: var(--uhcp-emerald); font-size: .78rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; }
205
+ .uhcp-access-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 auto; max-width: 1160px; padding: clamp(1rem, 4vw, 3rem); }
206
+ .uhcp-access-card {
207
+ background: var(--uhcp-panel);
208
+ border: 1px solid var(--uhcp-border);
209
+ border-radius: 2rem;
210
+ color: var(--uhcp-text);
211
+ display: grid;
212
+ gap: .75rem;
213
+ min-height: 15rem;
214
+ padding: clamp(1.5rem, 4vw, 2.5rem);
215
+ text-decoration: none;
216
+ transition: border-color .2s, transform .2s;
217
+ }
218
+ .uhcp-access-card:hover { border-color: var(--uhcp-turquoise); transform: translateY(-2px); }
219
+ .uhcp-access-card:focus-visible, .uhcp-trigger:focus-visible, .uhcp-menu a:focus-visible, .uhcp-menu button:focus-visible { outline: 3px solid var(--uhcp-turquoise); outline-offset: 3px; }
220
+ .uhcp-access-card strong { font-size: 1.45rem; }
221
+ .uhcp-access-card > span:not(.uhcp-access-number) { color: var(--uhcp-muted); line-height: 1.6; }
222
+ .uhcp-access-card small { color: var(--uhcp-emerald); font-weight: 800; }
223
+ .uhcp-access-number { color: var(--uhcp-turquoise); font-size: .8rem; font-weight: 900; letter-spacing: .16em; }
224
+ .uhcp-floating-action { align-items: center; background: linear-gradient(135deg, var(--uhcp-action-start), var(--uhcp-action-end)); border: 0; box-shadow: 0 12px 30px rgb(0 0 0 / 20%); color: #fff; display: flex; font: inherit; font-weight: 800; justify-content: center; position: fixed; text-decoration: none; z-index: 30; }
225
+ .uhcp-floating-back { border-radius: 50%; bottom: 1.25rem; height: 3.5rem; left: 1.25rem; width: 3.5rem; }
226
+ .uhcp-floating-assistant { border-radius: 999px; bottom: 1.25rem; gap: .5rem; padding: 1rem 1.25rem; right: 1.25rem; }
227
+ @media (max-width: 620px) {
228
+ .uhcp-account-id, .uhcp-brand small { display: none; }
229
+ .uhcp-entry-hero { grid-template-columns: 1fr; }
230
+ .uhcp-access-grid { grid-template-columns: 1fr; }
231
+ }
232
+ @media (prefers-reduced-motion: reduce) {
233
+ .uhcp-access-card { transition: none; }
234
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "uhc-portal-components-react",
3
+ "version": "0.1.3",
4
+ "description": "Brand-neutral React portal shell, account preferences and protected-profile UI for UHC and VetChain portals.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "files": ["dist", "README.md", "CHANGELOG.md"],
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./styles.css": "./dist/styles.css"
16
+ },
17
+ "scripts": {
18
+ "clean": "rm -rf dist",
19
+ "build": "npm run clean && tsc && cp src/styles.css dist/styles.css",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "check": "npm run typecheck && npm test && npm run build",
23
+ "prepack": "npm run check"
24
+ },
25
+ "peerDependencies": {
26
+ "react": ">=18.3.0",
27
+ "react-dom": ">=18.3.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "19.2.17",
31
+ "@types/react-dom": "19.2.3",
32
+ "react": "19.2.8",
33
+ "react-dom": "19.2.8",
34
+ "typescript": "^5.5.4",
35
+ "vitest": "^4.1.10"
36
+ }
37
+ }