import { useState } from "react"; import { Link } from "react-router-dom"; import { ChevronDown } from "lucide-react"; import type { Organization } from "fhir/r4"; import { ORG_LOGO_EXTENSION_URL } from "../../config/organizationExtensions"; import { resolveHeaderBranding } from "./headerBranding"; import { usePracticeSwitcher } from "../PracticeSwitcher/PracticeSwitcherProvider.tsx"; import { initialsOf } from "../ui/org-badge"; import { assetUrl } from "../../utils/assetUrl"; import { SandboxModeControl } from "../SandboxMode/SandboxModeControl.tsx"; import { sandboxOffered } from "../SandboxMode/useSandboxEntry"; /** * The portal's own minimal header -, design-direction §1b/§1c items 1, 4, 5, 6, 10, 11. * * WHY THIS EXISTS AT ALL * ---------------------- * The frame keys off AUTHENTICATION STATE. Logged OUT you are on a public page of the practice's * website and you keep their real header and menu. Logged IN you are in the app, their chrome is * suppressed, and this stands in its place. * * WE RENDER OURS RATHER THAN HIDING THEIRS. Every detect-and-hide approach is archaeology on a * theme we have never seen: it assumes
is used properly, it cannot know WHICH menu is the * site menu, and it breaks on their next theme update. Rendering our own is deterministic - we know * what is there because we put it there. * * It also dissolves the mobile two-hamburger collision on its own: logged out only THEIR hamburger * exists, logged in only ours does. There is no state in which both are on screen. * * THE FOUR CONSTRAINTS THAT ARE NOT STYLING * ----------------------------------------- * 1. THE LOGO COMES FROM THE ORGANIZATION RECORD, delivered through the API - never a WordPress * option or a media-library id. The standalone client at akashic.health cannot read a WP option, * and a WordPress-shaped input here is exactly what makes the two clients diverge (§1c item 4). * 2. THE TITLE LINE IS NOT AN

. The 54px WordPress page title being removed is a DUPLICATE * heading (finding P3), and heading structure is already a live defect (B2, B10). Printing the * practice name as an

here would recreate in our own header the bug we just deleted from * theirs. The page's own heading stays the h1; this is a banner with plain text. * 3. ALT TEXT IS THE PRACTICE'S NAME, never "Logo" - finding B3 caught exactly that mistake. * 4. NO LOGO RENDER THE PRACTICE NAME AS TEXT. Never a broken image, and never Akashic's own * mark standing in for theirs: on the practice's own domain that reads as us branding them. * 5. AN IMAGE THAT WAS ASKED FOR AND FAILED IS A FIFTH CASE, NOT CONSTRAINT 4. Spencer, 2026-08-17: * *"If they upload an image and it doesn't display for some reason then it falls back to the alt text * which would be the org's name with a broken image indicator so they know the image isn't loading for * some reason and instead showing the org name, otherwise they could think the options they set just * aren't working."* The reason is the rule: the person looking at this header is usually the person * who just changed the setting, so a SILENT fallback tells them their setting is broken when their * image is. Constraint 4 stays exactly as it is - *nothing configured* still renders the plain name * with no indicator. */ /** * Constraint 5's hint. Exported so the guard asserts the STRING a practice actually reads rather than a * copy of it - a test that re-types the sentence passes while the header says something else. */ export const IMAGE_FAILED_HINT = "The practice's header image could not load. Its name is shown instead."; /** * The brand mark, which is either a way HOME or a way to the patient's other practices. * * ONE ELEMENT, TWO JOBS, AND THE CHOICE IS NOT THIS COMPONENT'S. `usePracticeSwitcher` answers * `canSwitch` - false for staff, whose frame has no provider, and false for a patient with a single * practice. So the header does not ask about roles or counts; it asks whether there is anywhere else * to go, which is the only thing that changes what this control should be. * * A LINK AND A BUTTON, never a link that behaves like a button. The home case must keep middle-click * and open-in-new-tab, and the switcher case is not navigation at all. */ const BrandLockup = ({ practiceName, children, }: { practiceName: string; children: React.ReactNode; }) => { const { canSwitch, openPracticeSwitcher } = usePracticeSwitcher(); // Staff who can reach the sandbox choose the mode here, from the practice name. if (!canSwitch && sandboxOffered()) { return {children}; } if (!canSwitch) { return ( {children} ); } return ( ); }; /** The logo is a valueAttachment; `url` is the hosted image. Absent for most orgs, which is normal. */ export const getOrganizationLogoUrl = (organization?: Organization | null): string | undefined => { const attachment = organization?.extension?.find((e) => e.url === ORG_LOGO_EXTENSION_URL) ?.valueAttachment; const url = attachment?.url?.trim(); return url ? url : undefined; }; export type PortalFrameHeaderProps = { organization?: Organization | null; /** Optional short line beside the brand. Deliberately plain text - see constraint 2. */ titleLine?: string; /** * - THE WHOLE SECOND LINE, and it is a slot for the same reason `contact` was. * * The settled mockup gives this header two lines: the brand lockup on top, and beneath it how to reach * the practice plus what it offers. Both halves of that second line are facts about a PRACTICE told * to a PATIENT, so neither belongs to a receptionist looking at their own org - a practice does not * need its own phone number read back to it, and "Schedule appointment" in a staff frame is a control * aimed at the wrong person. * * SO THE CALLER DECIDES WHETHER THERE IS A SECOND LINE AT ALL, not this component. React cannot see * that an element rendered `null`, so a slot that self-gates on role would still leave this header * reserving a row's worth of gap for staff. Passing `undefined` is the only way "there is no second * line" can be true rather than merely invisible. * Which keeps the frame ONE component for both deployments and every role, with the role decision * made where the role is known - exactly the property the old `contact` slot had. */ contactRow?: React.ReactNode; /** * Who the viewer is, shown at the right of line one. * * A slot for the same reason contactRow is: the role test is asked once, where the role is known, and * `undefined` rather than an element that renders nothing - React cannot see that a child returned * null, so passing one would make every staff frame reserve the gap forever. */ identity?: React.ReactNode; /** * The practice's address, stacked under its name in the brand block. * * A PROP RATHER THAN A LOOKUP, for exactly the reason `contactRow` and `identity` are. It is a fact * about a PRACTICE told to a PATIENT: staff know where they work, and reading a practice its own * street address in its own header is noise on every screen they open. The role decision is made * where the role is known, which keeps this one component right for both deployments. */ practiceAddress?: string; /** Row one, between the brand and the viewer's own group: the workspace's standing actions. */ actions?: React.ReactNode; /** Row one, at the right: the viewer's own chip, and whatever else the frame is given. */ children?: React.ReactNode; }; /** Below this, in either dimension, an image cannot show anything on the 30px mark. */ const MIN_LOGO_PX = 8; export const PortalFrameHeader = ({ organization, titleLine, contactRow, identity, practiceAddress, actions, children, }: PortalFrameHeaderProps) => { const branding = resolveHeaderBranding(organization); // A practice with no name on record is a broken record, not a design case - but rendering the word // "undefined" at the top of a patient's screen is worse than rendering nothing, so it degrades. const practiceName = organization?.name?.trim() || ""; // Constraint 5. Only the browser can know an image did not load, so this is the component's state and // not the resolver's. Keyed by URL so a practice swapping the image re-tries it rather than staying // stuck on a failure that belonged to the previous file. const [failedUrls, setFailedUrls] = useState([]); const failed = (url?: string) => Boolean(url && failedUrls.includes(url)); const noteFailure = (url: string) => setFailedUrls((current) => (current.includes(url) ? current : [...current, url])); const logoUrl = failed(branding.logoUrl) ? undefined : branding.logoUrl; return ( /* Tailwind on the SEMANTIC tokens - `bg-card`, `border-border`, `text-foreground` - never raw hex and never a bespoke class name with no stylesheet behind it. Because those tokens now read the warm ramp, this header follows the palette (and an eventual dark mode) for free. ZERO WordPress components here: a plain
with our own utilities. This is the frame for BOTH deployments, and a @wordpress/* import is meaningless on akashic.health. */
elements, so a bare `header{display:none}` would take ours out too and leave the app with no header at all. Removing this attribute is not a tidy-up - it makes the portal headerless. Pinned by portalFrameAuthGate.test.ts. */ data-akashic-frame="" /* `relative` only matters in the background case, and it is unconditional so the two shapes cannot drift apart - the mockup states as a RULE that the chrome does not move between them. */ className="relative border-b border-border bg-card px-4 py-2.5" > {/* ── LINE ONE — who this is ───────────────────────────────────────────────────────────── The brand lockup, and at the right whatever the frame was given for the viewer. */} {/* `relative z-10` puts the words above the picture and its scrim. On the dark treatment plain text goes white and anything that sets its own colour keeps it, which is why the quick actions and the badges are untouched by the switch. */} {/* `ak-frame-row` caps this at the same content column the page below uses. The BAND still spans the window - it carries the bottom border, and a band that stops short reads as a floating card - but its CONTENTS line up with the rows underneath. */}
{/* The logo goes to the portal dashboard, not to the website — app convention, and it leaves exactly ONE labelled way out (sign out), which does exactly one thing (§1c items 6, 7). UNLESS THE PATIENT HAS SOMEWHERE ELSE TO BE, in which case the same mark opens their practices instead. It is the one place in the portal that already names the practice you are in, so it is where the question "and how do I change it?" belongs. A patient with ONE practice keeps the link: a chevron that opens a list of one implies somewhere else to be, which is worse than no chevron at all. Staff keep it too — the hook reports `canSwitch: false` when its provider is absent. */} {practiceName ? (<> {/* Organization marks are always round. A logo sits inside the disc; a practice without one gets its initials on the same disc, so the brand block never loses its mark. */} {logoUrl ? ( Logo tells a screen-reader user nothing about whose portal they are in. */ alt={practiceName} onError={() => noteFailure(logoUrl)} /* A logo that loads but is too small to show anything is treated as one that failed. Tab Close stores a 1x1 red PNG, which drew a blank red disc where its initials belong. */ onLoad={(event) => { const { naturalWidth, naturalHeight } = event.currentTarget; if (naturalWidth < MIN_LOGO_PX || naturalHeight < MIN_LOGO_PX) noteFailure(logoUrl); }} data-testid="portal-frame-logo" /> ) : ( )} {/* The practice name, unconditionally. It used to be a switch, and its default was OFF whenever an image would render - so the ordinary outcome for a practice that uploaded a banner was a patient portal header that never said whose practice it was. The banner is gone, and with it the reason the name was ever optional. Every approved deck draws it as text beside the mark. No broken-image indicator either. It existed because a failed image left a header that looked misconfigured to the person who had just configured it; with the name always present there is nothing to fall back FROM. A logo that fails to load simply leaves the name, which is the same thing a practice without a logo sees. */} {/* THE NAME AND THE ADDRESS ARE ONE COLUMN, which is how every deck draws the brand block: the mark, then the practice's name with its address stacked beneath it, then the chevron. THE ADDRESS CAME UP FROM LINE TWO, where it had been appended to the telephone number after a middot. The two answer different questions - the name and the address together say who and WHERE this practice is, while the number is something you DO - and beside the number it read as the second half of one control. The deck's own note makes the same point from the other side: the address is context beside the number, never inside it. IT STILL DROPS BELOW `lg`, unchanged. Nobody navigates by an address read off a phone header; they tap the number, which is why the number is the thing that survives to mobile as text. Moving the address has not made it more important, only better placed. */} {/* `text-left` is EXPLICIT, not redundant: both lines inherited `text-align: center` from an ancestor and only looked left because the name filled its box. Spencer, 2026-09-08 - the sign-in screen is the one place anything centres. */} {/* The staff chrome's brand is the deck's 14px; on a phone its mark alone carries it, leaving the row to the actions. */} {practiceName} {practiceAddress ? ( {practiceAddress} ) : null} ) : ( // The unbranded portal names no practice: the full Akashic logo sits where a practice's mark and name // would, and carries the name itself. Akashic Health Records )} {/* Constraint 2 — a , never a heading. */} {titleLine ? ( {titleLine} ) : null} {actions} {/* The right-hand action group, and a DOM anchor other components portal into. The AI assistant is not one of them: it sits on the breadcrumb row below the header as an icon alone (SetupHelpWidget). */}
{/* The viewer's own identity sits first in this group, which is where every deck draws it - before the menu button. It renders nothing for anyone who is not a patient or acting for one, so the staff frame is untouched. Rendered here rather than portalled: the portal targets THIS node and appends, so anything portalled in lands after it, which is the order the decks show. */} {identity} {children}
{/* ── LINE TWO — how to reach this practice, and what it offers ────────────────────────── Card 305 R1 put these WITH the brand because they are facts about the same practice; card 722 gives them a line of their own because there are now three actions and a phone number, and one row could not hold them without the number being the thing that fell off. Rendered only when the caller supplied one — see `contactRow`. A staff frame has no second line at all rather than an empty one, so this header is unchanged for every role but the patient's. */} {/* ONE ROW: the practice's number on the left, its quick actions hard right. I STACKED THESE ONTO TWO LINES FIRST AND IT WAS THE WRONG FIX. The row was overflowing because the buttons still carried their full labels at 390px — not because a row could not hold them. Once the labels hide, a number and two 44px icons fit inside 375px with room to spare, and stacking them left a band of empty space that Spencer circled on sight. The deck says so outright: its header is a two-column grid, `"brand brand" / "sub util"`, so the subline and the quick row share row two at every width. Read the drawing's LAYOUT, not just its parts. */} {contactRow ? (
{contactRow}
) : null}
); }; export default PortalFrameHeader;