Files
hanzo.ai/app/providers.tsx
zeekay 8bb3c633d2 consent: @hanzo/ui's one sheet, and our stream counts every visitor
components/consent/Consent.tsx and Choices.tsx are gone. app/providers.tsx
mounts Consent from @hanzo/ui/consent 8.29.14: a bar with Manage
preferences, Accept all and a × that stores essential cookies only, and
a Cookie Preferences sheet that opens from any link to #choices, the
footer's Privacy choices included. Its switches start off until chosen.

@hanzo/event 0.3.61 makes the stream first party: every page view and
event goes for every visitor, stating the grants it went under, so the
provider no longer takes `enabled` from consent. Consent gates Google
and Meta alone, through startTags.

The conversion and forward gates now hold that no platform hears of a
visit before Accept or after a refusal, while our stream counts it under
no grant. Help's corner words gain the Font and Arabic font rows
@hanzo/ui 8.29.12 added to Words.
2026-10-25 22:05:45 -07:00

614 lines
28 KiB
TypeScript

"use client";
import "@/lib/app";
import { claimsOf, IamProvider, useIam } from "@hanzo/iam/react";
import { asked, assumed, bearer, carry, follow, live, ORG, own, subject, unasked } from "@hanzo/app/lib/session";
import { bind, keep } from "@/lib/hanzo/affiliate";
import { claim, keep as hold } from "@/lib/hanzo/referrals";
import {
AnalyticsProvider,
ErrorBoundary,
useAnalytics,
usePageview,
} from "@hanzo/event/react";
import { ObserveProvider } from "@hanzo/observe/react";
import { usePathname } from "next/navigation";
import { useEffect, useRef, type ReactNode } from "react";
import NextLink from "next/link";
import { Box, Link } from "@hanzo/ui";
import { api, CLIENT } from "@hanzo/app/lib/api";
import { door, LOGIN, recognize, rememberDestination, signUp } from "@hanzo/app/lib/destination";
import { markMethod } from "@hanzo/app/lib/intent";
import { here, useClient } from "@hanzo/app/lib/signout";
import { foreign } from "@/lib/auth/signin";
import { attach, authorize, DOMAINS, link, startTags, track } from "@hanzo/app/lib/tags";
import { noise } from "@/lib/analytics/noise";
import { Analytics } from "@hanzo/event";
import { Consent } from "@hanzo/ui/consent";
import { Help } from "@/components/shell/help";
import { SupportBanner } from "@hanzo/app/Support";
import { Impersonation, Personal, useImpersonation } from "@/components/auth/Impersonation";
import { impersonated } from "@/lib/auth/impersonation";
import { arrive } from "@/lib/campaign";
import { Look } from "@hanzo/app";
import { I18nProvider } from "@hanzo/i18n/react";
import { Action, Line } from '@hanzo/ui/marketing'
/** Cloud endpoint: POST /v1/event, body { batch: [Event…] }. */
const getEventHost = () => api();
/**
* THERE IS ONE ANALYTICS CLIENT, and this is what keeps it to one.
*
* `@hanzo/ui` depends on `@hanzogui/telemetry`, whose `track` builds an AMBIENT
* client the first time any shared component reports an interaction — zero-config
* by design, `pageviews` on by default. So a page carrying both it and the
* `AnalyticsProvider` below sends `$pageview` TWICE. Measured on one load of
* /pricing: two POSTs to /v1/event, the second carrying its own `$pageview`.
*
* The second one is also UNKEYED, and that is the half that actually breaks in
* production: anonymous traffic without a `pk-` is answered
* `401 ingest_key_required`, which is a console error on every visit.
*
* Turning it off costs nothing that works today. Its interaction events
* (`FEATURE_USED`) ride that same keyless send, and `<ObserveProvider>` already
* captures clicks, inputs, changes and submits through the ONE keyed client.
*
* Module scope, not an effect: the ambient client is built on FIRST USE during
* render, and this file's chunk is evaluated before its component runs.
*
* (The duplicate is NOT a mislabelling. Read locally it reports `product: "dev"`,
* which is `productFromHost` mapping 127.0.0.1 — on hanzo.ai that same table maps
* the host to `site`, so in production it was silently doubling one number rather
* than splitting it across two.)
*/
if (typeof globalThis !== "undefined") {
const g = globalThis as { __HANZO_TELEMETRY__?: { enabled?: boolean } };
g.__HANZO_TELEMETRY__ = { ...g.__HANZO_TELEMETRY__, enabled: false };
}
/**
* The client `track` is handed, for the page's life: it sends on whichever
* client the provider holds when a moment goes out.
*
* Our stream is first-party analytics and runs for every visitor, whatever they
* chose; consent gates the third parties alone (`startTags`). @hanzo/event holds
* a moment fired before the tag set has answered and sends it later on the
* client it was fired with, so `track` follows the provider's client rather than
* keeping the one it was first handed.
*/
class Stream extends Analytics {
client?: Analytics;
capture(...args: Parameters<Analytics["capture"]>): void {
this.client?.capture(...args);
}
link(url: string): string {
return this.client ? this.client.link(url) : url;
}
authorize(url: string): string {
return this.client ? this.client.authorize(url) : url;
}
}
const stream = new Stream({ product: "site", enabled: false });
attach(stream);
/** The funnel moment a click to one of these places is: a host, or a host and
* the path it is mounted at. A link marked `data-ga` sends its own, with the
* item and value it knows. */
const INTENT: Record<string, string> = {
"hanzo.ai/pay": "checkout_started",
"billing.hanzo.ai": "checkout_started",
"cal.com/hanzo": "sales_contacted",
};
/** The INTENT a link reaches: its host alone, or its host and a mounted path. */
function intent(href: string): string | undefined {
const u = new URL(href, window.location.href);
const at = u.host + u.pathname;
const key = Object.keys(INTENT).find((k) => at === k || at.startsWith(k + "/"));
return key && INTENT[key];
}
/**
* The funnel's wiring (lib/analytics/tags.ts): points `track`'s stream at the
* provider's client, starts the tag manager, and sends the
* moment a click to the checkout or a booking host is.
*/
function Tags() {
const client = useAnalytics();
useEffect(() => {
stream.client = client;
}, [client]);
// The campaign this visit landed with, kept for the tab: a sign-in takes it
// out of the address, and pay is handed it all the same (lib/campaign).
useEffect(() => arrive(window.location.search), []);
// A link to another Hanzo host carries the visitor, however it is opened:
// the address is decorated before a click, a middle click or a copy takes it.
useEffect(() => {
const carry = (e: Event) => {
const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
if (a) a.href = link(a.href);
};
const on = ["pointerdown", "click", "contextmenu", "keydown"] as const;
on.forEach((t) => document.addEventListener(t, carry, { capture: true }));
return () => on.forEach((t) => document.removeEventListener(t, carry, { capture: true }));
}, []);
// The tag manager: the host's own tag set from cloud, loaded as consent
// allows, and this page's ad click kept first-party. One export serves
// several hosts (hanzo.app, hanzo.chat), so the set is asked for with the
// host's own project key (@hanzo/event `siteKey`). The signed-in person's
// bearer tells cloud a member of our own org is visiting: their browser loads
// Google Analytics alone, tagged internal, and no ad pixel. A support
// session's token is the person's, not the visitor's, so it asks as nobody.
useEffect(
() =>
startTags({
domains: [window.location.hostname, ...DOMAINS],
token: () => {
const token = live() ? bearer() : null;
return token && !impersonated(claimsOf(token)) ? token : undefined;
},
}),
[],
);
useEffect(() => {
const click = (e: MouseEvent) => {
const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!a || a.hasAttribute("data-ga")) return;
const name = intent(a.href);
if (!name) return;
track(name, { link_url: a.href, page_path: window.location.pathname });
};
document.addEventListener("click", click, { capture: true });
return () => document.removeEventListener("click", click, { capture: true });
}, []);
return null;
}
/**
* `<html data-hydrated>` once React has committed the page: rendered last, so its
* effect runs after every other on the first commit. A press before it follows the
* bare link; anything that presses (a gate, a script) waits for it.
*/
function Hydrated() {
useEffect(() => document.documentElement.setAttribute("data-hydrated", ""), []);
return null;
}
/**
* Unhandled errors and rejections, reported as the SDK would (`captureErrors`,
* off on the provider) less what is not this site's to fix
* (lib/analytics/noise.ts): a scanner's, an extension's, another origin's.
*/
function Errors() {
const client = useAnalytics();
useEffect(() => {
const report = (err: unknown) => {
if (!noise(err)) client.captureError(err, { handled: false });
};
const error = (e: ErrorEvent) => report(e.error ?? e.message);
const rejection = (e: PromiseRejectionEvent) => report(e.reason);
window.addEventListener("error", error);
window.addEventListener("unhandledrejection", rejection);
return () => {
window.removeEventListener("error", error);
window.removeEventListener("unhandledrejection", rejection);
};
}, [client]);
return null;
}
/** Route-change pageviews. Browser-only; safe under `output: export`. */
function Pageview() {
usePageview(usePathname());
return null;
}
/**
* The ONE place this site binds telemetry identity — mounted inside IamProvider
* so it sees the resolved session on every route, not just /auth/callback.
*
* identify(sub, traits) — the stable IAM subject, which is what joins a person's
* events across hanzo.ai and Hanzo Cloud (which stamps the same subject
* server-side), plus the attributes that make that subject legible.
*
* THE TRAITS ARE NEW, AND THEY REVERSE A STATED RULE. This said "NEVER
* email/name: the client is PII-free by construction". PII-free was the wrong
* goal for first-party data about our own users: it produced a warehouse of
* opaque subjects where every funnel could count people and none could say which
* person, so no user attribute was visible on any Hanzo property. Email and name
* arrive in the same hanzo.id userinfo response this file already reads to get
* `sub`, and were discarded on the way past. They are exactly as sensitive as
* they were in that response, and only ever describe the signed-in user to
* ourselves: identify runs only once a session has resolved a user.
*
* The traits go to our own stream, which is first party and runs whatever the
* visitor chose; anything cloud forwards on to an ad platform goes under the
* grants each event states.
*
* THE CLIENT DOES NOT SEND THE ORG. There is deliberately no group() call here.
* The tenant is stamped SERVER-SIDE from the project key, so the hanzo org's
* funnels and cohorts are queryable without the browser naming a tenant — and
* a tenant a client can name is a tenant a client can get wrong. An
* earlier revision called group(user.owner) to "make ORG-level funnels
* queryable"; it bought nothing (the server had already stamped that very org
* from the same session) and it put an org name in a caller-controlled field.
* Cloud agrees on both counts: it strips groupId from every reduced principal
* and drops `group` outright on the anonymous lane, so the call was discarded
* in exactly the cases where trusting it would have mattered.
*/
function Identity() {
const { user, isAuthenticated, isLoading, accessToken } = useIam();
const analytics = useAnalytics();
// A support session is the operator's visit, not the person's: it is never
// joined to them, and nothing is bound or claimed in their name.
const support = useImpersonation() !== null;
const who = isAuthenticated ? iamSubject(user) : undefined;
const traits = isAuthenticated ? iamTraits(user) : undefined;
useEffect(() => {
if (!who || support) return;
// This browser has a person now; signed out later, it is sent to log in, not to sign up.
recognize();
analytics.identify(who, traits);
// `traits` is read off the same userinfo response as `who`, so the
// subject changing is what marks a new person. Adding the freshly-built
// object to the deps would re-identify on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [analytics, who, support]);
// The browser's local state follows the session: once the SDK has settled
// (a sign-out clears its tokens; a refused refresh leaves them), whoever the
// stored token names — or nobody — is who the `hanzo*` keys belong to.
useEffect(() => {
if (!isLoading) own(subject());
}, [isLoading, isAuthenticated]);
// Another tab's sign-out or sign-in is this tab's too (lib/auth/session.ts
// `follow`). A reload settles the page on what the store now holds: a reader
// signed out at a place in the app goes to sign in and back to it, and another
// person is drawn as themselves with the last one's keys emptied. Followed
// from the first render, while the SDK is still settling on the token it read
// then, and again from whatever this tab's own sign-in or sign-out leaves.
useEffect(() => follow(subject(), () => window.location.reload()), [isAuthenticated]);
// A SESSION IAM HOLDS SIGNS THIS SITE IN ON ANY PAGE. hanzo.ai and
// platform.hanzo.ai sign in on hanzo.ai's one IAM session, but this site's own
// tokens are kept apart: signed in for platform, or with this site's tokens
// expired, a reader on /pricing or /chat was drawn as a stranger while the
// session that would sign them in sat unused until they opened /login. So the
// same silent ask the sign-in page makes (components/auth/panel.tsx `Gate`) is
// made here, wherever the page is: once per tab, only by a browser that has
// signed in here (`door`), never on the page a sign-out lands on, and not on
// the sign-in pages and callback, which make or finish their own. A code comes
// back to this page (`rememberDestination`); a refusal changes nothing.
const iam = useClient();
useEffect(() => {
if (isLoading || isAuthenticated || !here()) return;
const { pathname, search } = window.location;
if (pathname === LOGIN || pathname === signUp() || pathname.startsWith("/auth/") || foreign()) return;
if (new URLSearchParams(search).get("from") === "logout" || door() !== LOGIN || !unasked()) return;
asked();
void iam.resume().then((next) => {
if (!next) return;
markMethod("session");
rememberDestination();
window.location.replace(authorize(next));
});
}, [isLoading, isAuthenticated, iam]);
// The organization a SuperAdmin stepped into is this page's scope. When this
// tab's own refresh ends it — the SDK mints from the person's own grant, which
// assumes nothing — the page reloads, so no room keeps reading under a scope
// the token no longer carries.
const scoped = useRef<string | null | undefined>(undefined);
useEffect(() => {
const now = assumed();
if (scoped.current === undefined) scoped.current = now;
else if (now !== scoped.current) window.location.reload();
}, [accessToken]);
// An affiliate link lands here (lib/hanzo/affiliate.ts): keep its code, and
// bind it once the session works in an organization.
useEffect(keep, []);
// A referral link (`?ref=`, lib/hanzo/referrals.ts) is the same two halves:
// keep the code on landing, claim it once the session works in an org.
useEffect(hold, []);
useEffect(() => {
if (isLoading || !isAuthenticated || support) return;
const on = () => {
void bind();
void claim();
};
on();
window.addEventListener(ORG, on);
return () => window.removeEventListener(ORG, on);
}, [isLoading, isAuthenticated, support]);
return null;
}
/**
* The language the site's own words are drawn in (@hanzo/i18n), and with it the
* document's `lang` and `dir`: Arabic sets `dir="rtl"` on <html>, and the chrome
* follows on logical properties.
*
* The export is English; a saved choice applies after hydration. It is kept per
* person on this browser and, signed in, with their IAM account
* (`/v1/iam/preferences`, the `locale` key alone), so it follows them to every
* Hanzo surface. The person is the stored token's subject, the same key `own()`
* empties a browser by, so a signed-in reader's choice applies on the first
* client render rather than after userinfo. A support session writes nothing as
* the person: the operator's choice stays on the operator's browser.
*/
function Language({ children }: { children: ReactNode }) {
const { isAuthenticated, accessToken } = useIam();
const support = useImpersonation() !== null;
const who = subject() ?? null;
const account = who && isAuthenticated && accessToken && !support ? { base: api(), token: accessToken } : undefined;
return (
<I18nProvider userId={who} account={account}>
{children}
</I18nProvider>
);
}
/**
* The IAM subject (`sub`) off the SDK's user object.
*
* This reads `sub` and not `id` because `id` DOES NOT EXIST at runtime, which made
* the identify above a silent no-op for as long as it has been here — hanzo.ai
* recorded thousands of pageviews and not one identified person. `IamProvider` sets
* `user` to the raw OIDC userinfo response (`sdk.getUserInfo()` returns
* `res.json()` verbatim), and hanzo.id's userinfo returns
* `{owner, organization, email, iss, aud, preferred_username, name, displayName,
* picture, sub}` — no `id` anywhere. The SDK's `User` type merely declares `id?`
* as optional, so reading it type-checks and yields undefined forever; nothing
* fails, the call just never happens. (`IAM#getUser()` — a DIFFERENT method this
* provider does not use — is the one that maps userinfo onto a `sub`-bearing
* shape.)
*
* `sub` is the stable IAM user UUID, the same value hanzo.app, hanzo.chat and
* cloud.hanzo.ai identify this human by. Read defensively: the provider's declared
* type does not include `sub`, and the value is a network response.
*/
function iamSubject(user: unknown): string | undefined {
if (!user || typeof user !== "object") return undefined;
const sub = (user as { sub?: unknown }).sub;
return typeof sub === "string" && sub ? sub : undefined;
}
/**
* The user attributes that ride the subject, off the same userinfo response.
*
* hanzo.id returns `{owner, organization, email, iss, aud, preferred_username,
* name, displayName, picture, sub}`, so the name is read in descending order of
* how human it is: `displayName`, then `name`, then `preferred_username`. Read
* as defensively as `iamSubject` — the provider's declared type does not include
* these and the value is a network response.
*
* A key is OMITTED rather than sent undefined: a trait sent as undefined is a
* trait written, and it would blank a value an earlier identify established.
* Returns undefined when nothing resolved, so identify falls back to sending the
* subject alone rather than an empty object.
*/
export function iamTraits(user: unknown): Record<string, unknown> | undefined {
if (!user || typeof user !== "object") return undefined;
const claim = (k: string): string | undefined => {
const v = (user as Record<string, unknown>)[k];
return typeof v === "string" && v ? v : undefined;
};
const traits: Record<string, unknown> = {};
const email = claim("email");
if (email) traits.email = email;
const name =
claim("displayName") ?? claim("name") ?? claim("preferred_username");
if (name) traits.name = name;
return Object.keys(traits).length ? traits : undefined;
}
/** Minimal on-brand fallback when a render error is caught. The boundary already
* posted it as a type:'error' event, which is what sentry.hanzo.ai reads, so
* this just keeps the page usable. */
function Crashed(_error: Error, reset: () => void) {
return (
<Box
display="flex"
flexDirection="column"
minH="100vh"
items="center"
justify="center"
gap="$4"
px="$5"
bg="$background"
$platform-web={{ textAlign: "center", color: "var(--foreground)" }}
>
<Line render="p" display="block" fontSize="$4" lineHeight="calc(1.75 / 1.125)" fontWeight="500" color="inherit">
Something went wrong.
</Line>
{/* `Action fill` is the site's loud control: white ground, black
ink, the pill radius, and the only hover it has. */}
<Action render="button" onClick={reset} fill>
Try again
</Action>
</Box>
);
}
/**
* The OAuth client a visitor authenticates as is decided by the HOST they landed
* on — this ONE static export serves several (hanzo.ai and cloud.hanzo.ai from the
* same `out/`, split by the CloudLanding `cp` in the Dockerfile). It mirrors
* provision.yaml's app->host graph: cloud.hanzo.ai is the Cloud surface and
* authenticates as `hanzo-cloud` — the ONLY client whose IAM
* allowlist carries its `/auth/callback`; every other host is `hanzo-app`.
*
* This is why cloud.hanzo.ai login 400'd: the shared build sent `hanzo-app`, whose
* allowlist has `cloud.hanzo.ai/callback` but not `/auth/callback` (the path the
* SDK actually sends, since redirectUri is origin + /auth/callback), so authorize
* was rejected and login was impossible. Deriving the client from the SAME host
* redirectUri already uses is what keeps the two from drifting.
* NEXT_PUBLIC_HANZO_CLIENT_ID still overrides for a single-host build.
*/
const cloudHosts = new Set(["cloud.hanzo.ai"]);
// The hanzo.ai domain authenticates as `hanzo-ai`, whose IAM allowlist is exactly
// these three hosts plus localhost:3000 — the surface's own addresses and nothing
// else. It was `hanzo-app`, a client this export shares with hanzo.app and its
// siblings, and that only held while nothing else authorized against this origin.
// The chat mounted at hanzo.ai/chat now asks for `hanzo-ai` and is returned HERE,
// and a code is bound to the client that asked for it: exchanging one under
// another name is refused `invalid_grant: client mismatch`. Signed-out visitors
// never reach it, because the silent probe answers login_required before a code
// exists, so the two names disagreeing is invisible until someone arrives already
// signed in.
function sanitizeStaleAudience() {
if (typeof window === "undefined") return;
try {
const token = localStorage.getItem("hanzo_iam_access_token");
if (!token) return;
const part = token.split(".")[1];
if (!part) return;
const payload = JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/")));
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (auds.includes("hanzo-ai") && !auds.includes("hanzo-app")) {
// Disallowed by production gateway: purge stale token so next login mints hanzo-app
localStorage.removeItem("hanzo_iam_access_token");
localStorage.removeItem("hanzo_iam_refresh_token");
}
} catch {
// Ignore parse errors
}
}
// A brand's own chat host signs in through its own IAM application at its own
// issuer: the export is one tree served under several hosts, so the host is
// the fact that picks them. Absent here means Hanzo's.
const brandHosts: Record<string, { client: string; issuer: string }> = {
"lux.chat": { client: "lux-chat", issuer: "https://lux.id" },
};
function brandForHost() {
return typeof window !== "undefined" ? brandHosts[window.location.hostname] : undefined;
}
/**
* WHERE THIS BROWSER SIGNS IN: the issuer, the client and the callback
* `IamProvider` below is configured with. A code is bound to the client that
* asked for it, so there is one derivation and every sign-in goes through it.
*/
function origin(): {
serverUrl: string;
proxyBaseUrl: string;
clientId: string;
redirectUri: string;
postLogoutRedirectUri: string;
organization: string;
} {
const here = typeof window !== "undefined" ? window.location.origin : "https://hanzo.ai";
const clientId = clientForHost();
const isLocal =
typeof window !== "undefined" &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const serverUrl =
brandForHost()?.issuer ?? (process.env.NEXT_PUBLIC_HANZO_IAM_URL || "https://api.hanzo.ai");
const proxyBaseUrl = isLocal ? here : serverUrl;
return {
serverUrl,
// The token, refresh and userinfo calls go to serverUrl too. Discovery names
// the issuer's own host for them (api.hanzo.ai serves hanzo.id's document), and
// the SDK follows discovery unless told otherwise; the authorize page stays
// where discovery puts it.
proxyBaseUrl,
clientId,
redirectUri: `${here}/auth/callback`,
// Signing out comes back to the sign-in page on this site, not to a page on hanzo.id.
postLogoutRedirectUri: `${here}/login?from=logout`,
// The org is the client's own first word. Applications are named
// `<org>-<app>` estate-wide — hanzo-app, hanzo-cloud, lux-chat — so the
// name already carries it and a second table mapping one to the other
// would be a table to forget to update. IAM's credential flow needs it.
organization: clientId.split("-")[0],
};
}
function clientForHost(): string {
if (process.env.NEXT_PUBLIC_HANZO_CLIENT_ID)
return process.env.NEXT_PUBLIC_HANZO_CLIENT_ID;
const brand = brandForHost();
if (brand) return brand.client;
if (typeof window !== "undefined" && cloudHosts.has(window.location.hostname))
return "hanzo-cloud";
// hanzo-app is the universal client accepted by api.hanzo.ai gateway
// and configured with hanzo.ai callback URLs in IAM.
return CLIENT;
}
/**
* Client providers. All telemetry is `@hanzo/event` posting to /v1/event; the
* analytics, insights and error dashboards are lenses cloud resolves over that
* one stream. `AnalyticsProvider` fires the first pageview, `<Errors/>` reports
* the unhandled errors that are this site's, `<Pageview/>` counts route changes,
* `ErrorBoundary` catches React render errors, which never reach window.onerror.
*
* `<ObserveProvider>` rides the same client through context and adds interaction
* capture ($click/$input/$change/$submit) with input values redacted.
* `nav={false}` leaves history alone, since `<Pageview/>` already counts routes.
*/
export function Providers({ children }: { children: ReactNode }) {
sanitizeStaleAudience();
carry();
// Before any room reads storage: a browser another person left behind is
// emptied of their conversations, contacts and settings (lib/auth/session.ts).
own(subject());
return (
<AnalyticsProvider
config={{
product: "site",
host: getEventHost(),
captureErrors: false,
}}
>
<ObserveProvider nav={false}>
<Errors />
<Pageview />
<Tags />
<ErrorBoundary fallback={Crashed}>
<IamProvider
config={{
...origin(),
// WHERE THE SESSION LIVES is the SDK's decision, and this used to
// override it with `sessionStorage` — which is per-TAB. Signing in
// was thrown away when the tab closed and was invisible to every
// other tab, so a link out, a middle-click or a restored window
// arrived signed out and asked again. The SDK resolves
// localStorage (shared by every tab, outlives it), probes that the
// browser will actually take a write, and falls back to memory for
// the prerender and for a profile that refuses storage. All three
// cases, already handled, once.
}}
>
<Identity />
{/* A support session's mark and its end (components/auth/Impersonation.tsx). */}
<Impersonation />
{/* The reader's look — theme, type, corners, accent — applied on
every page and kept in step with their IAM account; not in a
support session, where each sync is a write as the person. */}
<Personal>
<Look />
</Personal>
<Language>
<Consent />
<SupportBanner />
{/* Every @hanzo/ui control that takes an internal `href` routes
through Next's link, so it navigates client-side. */}
<Link value={NextLink}>
{children}
{/* Help and preferences, at the start corner of every site page
(components/shell/help.tsx). After the page, so it paints over
a band the page fixes on the same dock rung (/start's). */}
<Help />
</Link>
</Language>
<Hydrated />
</IamProvider>
</ErrorBoundary>
</ObserveProvider>
</AnalyticsProvider>
);
}