@the-portland-company/shell 0.82.0 → 0.84.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/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## 0.84.0
2
+
3
+ An organization switch lands on the new organization's Relationship Dashboard. Spec cited: Specs/Left-Navigation §6 and §11.2 (v21, decided 2026-10-15 by Ben; supersedes the 2026-10-06 rule that kept mode, page and tab).
4
+
5
+ - **Why.** Measured by Ben on production 2026-10-15: switching from "We need Steve" in Campaign Mode to an organization without Campaign Mode kept the Campaign rail, then showed an access error that required signing out and in again.
6
+ - **Token-org path.** After the IdP accepts the switch, the tab keeps the switch pending (Loading Screen up), lets the current app re-exchange for up to 4 seconds, then navigates to `https://dashboard.politogyvrm.com/dashboard?mode=relationship&org=<new org>` (`-staging` twin from a staging host). A refused switch still rolls back and calls `onSwitchError`.
7
+ - **Legacy path.** `ChromeStateProvider` navigates to the same landing after writing the cookie.
8
+ - **Frame.** The Loading Screen's synthetic ready signal waits while `orgPending` is true.
9
+ - **New export** `orgSwitchLanding` (`href`, `navigate`); tests replace `navigate`.
10
+ - **Not changed.** A per-tab `?org_id=` override and a switch made in another tab (followed on focus) do not navigate: neither is a switch made in this tab.
11
+
12
+ ## 0.83.0
13
+
14
+ The top bar's Credits icon shows credit health. Spec cited: Reference/UI-Platform v57 "Top bar → Credits" and "Credit health in the payload" (decided by Ben, 2026-10-13); VRM-Admin/Pricing-and-Metering v5 §7.5.
15
+
16
+ - **Icon.** The battery with the lightning bolt, coloured by the worst state among the credit types in use: green when every one is Good, yellow when at least one is Low, red when at least one is Out. Not in use never changes it. The fill drains with the state (full, part, empty) and the state is in the `aria-label` ("Credits: low on Signal Credits"), so colour is never the only signal. A payload without `state` is unknown: neutral icon, no colour, no gauge level.
17
+ - **Tooltip** (hover or focus) lists all four types, each with Available, a gauge against its full mark and the state in words ("Signal Credits: 38 left, about 1 day at your recent pace"). **Click** opens the breakdown: four rows, Available, Included (with the next grant date) and Purchased when the ledger publishes them, gauge, state, and a "+" to buy that type.
18
+ - **Retired.** The low-balance hint that hung under the icon ("You're low on fuel" / "Buy More Fuel"), its sessionStorage dismissal and the new-reply toast's clearance for it. No pop-up about credits opens by itself. `lowFuel` / `lowFuelThreshold` are no longer read. The word is Credits everywhere; "fuel" appears in no string.
19
+ - API: `FuelChannelStatus` gains `state`, `fullMarkUnits`, `daysOfUseLeft`, `includedUnits`, `purchasedUnits`, `nextGrantAt`; `CommunicationsFuelStatus` gains optional `ai` and `enhancement`. `TopBarPopover` gains `icon`, `ariaLabel` and `tooltip`; `topBarIconButtonClass` tones gain `ok` and `low` (`TopBarIconTone`). New export `CreditsAction` (alias of `FuelAction`) and the pure helpers from `./native` (`creditsIconState`, `creditGaugeRatio`, `creditTooltipLine`, `creditsAriaLabel`). New tokens `--credits-good`, `--credits-low`.
20
+ - Tests: `__tests__/credits.test.ts`, `__tests__/chromeActions.test.tsx` (Credits block).
21
+
22
+ ## 0.82.1
23
+
24
+ The legacy chrome provider (Contacts, and any app not yet on `tokenOrg`) writes the organization to the address bar as the 7-char `org_id` tail and never the full-uuid `?org=`. Spec cited: Reference/UI-Platform "URL state" (`org_id`, always present; the legacy `org=` and cookie stay only until every app reads the org from the token).
25
+
26
+ - **Cause.** The provider's sync effect set `?org=<full uuid>` whenever the active org changed, so Contacts' canonical URL regained a second org key after every load (reported by Ben's agent, 2026-10-13).
27
+ - **Fix.** The effect deletes `org=` and sets `org_id=<tail>` (a replace, never a navigation); the cookie mirror is unchanged. At boot an `org_id` tail counts as an explicit selector and, once the member list resolves, selects that org (uncontrolled apps); a non-member tail is rewritten to the active org.
28
+ - Tests: `__tests__/legacyOrgIdUrl.test.tsx`.
29
+
1
30
  ## 0.82.0
2
31
 
3
32
  The universal-search placeholder names what the current Mode searches. Spec cited: Reference/UI-Platform v56 "Top bar — required elements" (decided by Ben, 2026-10-12).
@@ -423,6 +423,81 @@ function SpeculationRulesScript({ items }) {
423
423
  return null;
424
424
  }
425
425
 
426
+ // src/native/credits.ts
427
+ var CREDIT_TYPE_ORDER = [
428
+ "signal",
429
+ "broadcast",
430
+ "ai",
431
+ "enhancement"
432
+ ];
433
+ var CREDIT_TYPE_LABELS = {
434
+ signal: "Signal Credits",
435
+ broadcast: "Broadcast Credits",
436
+ ai: "AI Credits",
437
+ enhancement: "Enhancement Credits"
438
+ };
439
+ var RANK = {
440
+ unknown: 0,
441
+ good: 1,
442
+ low: 2,
443
+ out: 3
444
+ };
445
+ function creditsIconState(channels) {
446
+ let worst = "unknown";
447
+ for (const channel of channels) {
448
+ const state = channel?.state ?? null;
449
+ if (state === null || state === "not_in_use") continue;
450
+ if (RANK[state] > RANK[worst]) worst = state;
451
+ }
452
+ return worst;
453
+ }
454
+ function creditGaugeRatio(channel) {
455
+ if (!channel || channel.state == null || channel.state === "not_in_use")
456
+ return null;
457
+ const fullMark = channel.fullMarkUnits ?? 0;
458
+ if (!(fullMark > 0)) return null;
459
+ return Math.min(1, Math.max(0, channel.remainingUnits / fullMark));
460
+ }
461
+ function creditStateWord(state) {
462
+ switch (state) {
463
+ case "good":
464
+ return "Good";
465
+ case "low":
466
+ return "Low";
467
+ case "out":
468
+ return "Out";
469
+ case "not_in_use":
470
+ return "Not in use";
471
+ default:
472
+ return "Unknown";
473
+ }
474
+ }
475
+ function daysPhrase(days) {
476
+ if (days < 1) return "less than a day at your recent pace";
477
+ const rounded = Math.round(days);
478
+ return `about ${rounded} ${rounded === 1 ? "day" : "days"} at your recent pace`;
479
+ }
480
+ function creditTooltipLine(type, channel) {
481
+ const label = CREDIT_TYPE_LABELS[type];
482
+ if (!channel) return `${label}: unavailable`;
483
+ const left = `${Math.max(0, channel.remainingUnits).toLocaleString()} left`;
484
+ if (channel.state === "not_in_use") return `${label}: not in use`;
485
+ if (channel.state == null) return `${label}: ${left}`;
486
+ if (channel.state === "out") return `${label}: ${left}, out`;
487
+ const days = channel.daysOfUseLeft;
488
+ const pace = typeof days === "number" && Number.isFinite(days) ? `, ${daysPhrase(days)}` : "";
489
+ return `${label}: ${left}${pace}`;
490
+ }
491
+ function creditsAriaLabel(channels) {
492
+ const worst = creditsIconState(CREDIT_TYPE_ORDER.map((t) => channels[t]));
493
+ if (worst === "unknown") return "Credits";
494
+ if (worst === "good") return "Credits: all good";
495
+ const named = CREDIT_TYPE_ORDER.filter(
496
+ (t) => channels[t]?.state === worst
497
+ ).map((t) => CREDIT_TYPE_LABELS[t]);
498
+ return `Credits: ${worst === "low" ? "low on" : "out of"} ${named.join(", ")}`;
499
+ }
500
+
426
501
  // src/notifications/adapter.ts
427
502
  function emptyNotificationsFeed() {
428
503
  return { notifications: [], badge: 0 };
@@ -517,7 +592,7 @@ function resolveOrgLogoUrl(url) {
517
592
  }
518
593
  var TOPBAR_GLYPH_SIZE = 17;
519
594
  function topBarIconButtonClass(tone = "default") {
520
- const toneClass = tone === "danger" || tone === "warning" ? "text-[color:var(--chrome-danger)] hover:text-[color:var(--chrome-danger)]" : "text-[color:var(--topbar-icon-color,var(--muted))] hover:text-[color:var(--topbar-icon-hover-color,var(--foreground))] hover:bg-[color:var(--topbar-icon-hover-bg,transparent)]";
595
+ const toneClass = tone === "danger" || tone === "warning" ? "text-[color:var(--chrome-danger)] hover:text-[color:var(--chrome-danger)]" : tone === "ok" ? "text-[color:var(--credits-good,#00783B)] hover:text-[color:var(--credits-good,#00783B)]" : tone === "low" ? "text-[color:var(--credits-low,#B7791F)] hover:text-[color:var(--credits-low,#B7791F)]" : "text-[color:var(--topbar-icon-color,var(--muted))] hover:text-[color:var(--topbar-icon-hover-color,var(--foreground))] hover:bg-[color:var(--topbar-icon-hover-bg,transparent)]";
521
596
  return [
522
597
  // `group/tbtip` powers the shared <TopBarTooltip> rendered inside the trigger.
523
598
  "group/tbtip relative flex items-center justify-center rounded-[var(--topbar-icon-button-radius,5px)] border border-transparent transition",
@@ -746,6 +821,9 @@ function ValueSkeleton({ className }) {
746
821
  }
747
822
  );
748
823
  }
824
+ function IconGlyph({ icon: Icon, size = TOPBAR_GLYPH_SIZE }) {
825
+ return /* @__PURE__ */ jsxRuntime.jsx(Icon, { size, className: "text-current" });
826
+ }
749
827
  function SlotGlyph({
750
828
  slot,
751
829
  fallback: Fallback,
@@ -758,9 +836,12 @@ function SlotGlyph({
758
836
  }
759
837
  function TopBarPopover({
760
838
  label,
839
+ ariaLabel,
761
840
  description,
841
+ tooltip,
762
842
  slot,
763
843
  fallbackIcon,
844
+ icon,
764
845
  badge,
765
846
  tone,
766
847
  onOpen,
@@ -810,7 +891,7 @@ function TopBarPopover({
810
891
  "button",
811
892
  {
812
893
  type: "button",
813
- "aria-label": label,
894
+ "aria-label": ariaLabel ?? label,
814
895
  "aria-expanded": open,
815
896
  className: topBarIconButtonClass(tone),
816
897
  onClick: () => {
@@ -821,9 +902,9 @@ function TopBarPopover({
821
902
  });
822
903
  },
823
904
  children: [
824
- /* @__PURE__ */ jsxRuntime.jsx(SlotGlyph, { slot, fallback: fallbackIcon }),
905
+ icon ? /* @__PURE__ */ jsxRuntime.jsx(IconGlyph, { icon }) : /* @__PURE__ */ jsxRuntime.jsx(SlotGlyph, { slot, fallback: fallbackIcon }),
825
906
  typeof badge === "number" ? /* @__PURE__ */ jsxRuntime.jsx(TopBarCountBadge, { count: badge }) : null,
826
- !open && !hideTooltip ? /* @__PURE__ */ jsxRuntime.jsx(TopBarTooltip, { title: label, description }) : null
907
+ !open && !hideTooltip ? /* @__PURE__ */ jsxRuntime.jsx(TopBarTooltip, { title: label, description: tooltip ?? description }) : null
827
908
  ]
828
909
  }
829
910
  ),
@@ -897,147 +978,145 @@ function XGlyph({ size = 14, className }) {
897
978
  function CheckGlyph({ size = 14, className }) {
898
979
  return /* @__PURE__ */ jsxRuntime.jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", className, "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "20 6 9 17 4 12" }) });
899
980
  }
900
- var LOW_FUEL_HINT_DISMISSED_KEY = "politogy_low_fuel_hint_dismissed";
901
- function FuelRow({
902
- label,
981
+ function batteryFillWidth(state) {
982
+ switch (state) {
983
+ case "good":
984
+ return 12;
985
+ case "low":
986
+ return 5;
987
+ default:
988
+ return 0;
989
+ }
990
+ }
991
+ function creditsBatteryGlyph(state) {
992
+ const fill = batteryFillWidth(state);
993
+ const Glyph = ({ size = TOPBAR_GLYPH_SIZE, className }) => /* @__PURE__ */ jsxRuntime.jsxs(
994
+ "svg",
995
+ {
996
+ width: size,
997
+ height: size,
998
+ viewBox: "0 0 24 24",
999
+ fill: "none",
1000
+ stroke: "currentColor",
1001
+ strokeWidth: 2,
1002
+ strokeLinecap: "round",
1003
+ strokeLinejoin: "round",
1004
+ className,
1005
+ "data-credits-state": state,
1006
+ "aria-hidden": true,
1007
+ children: [
1008
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "1", y: "6", width: "18", height: "12", rx: "2", ry: "2" }),
1009
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "23", y1: "10", x2: "23", y2: "14" }),
1010
+ fill > 0 ? /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "4", y: "9", width: fill, height: "6", rx: "1", fill: "currentColor", stroke: "none", opacity: 0.35 }) : null,
1011
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M11.5 8 8.5 12.5h3L10.5 16l3.5-4.5h-3L11.5 8z", fill: "currentColor", stroke: "none" })
1012
+ ]
1013
+ }
1014
+ );
1015
+ Glyph.displayName = `CreditsBatteryGlyph(${state})`;
1016
+ return Glyph;
1017
+ }
1018
+ function stateTone(state) {
1019
+ switch (state) {
1020
+ case "good":
1021
+ return "ok";
1022
+ case "low":
1023
+ return "low";
1024
+ case "out":
1025
+ return "danger";
1026
+ default:
1027
+ return "default";
1028
+ }
1029
+ }
1030
+ function stateColorVar(state) {
1031
+ switch (state) {
1032
+ case "good":
1033
+ return "var(--credits-good,#00783B)";
1034
+ case "low":
1035
+ return "var(--credits-low,#B7791F)";
1036
+ case "out":
1037
+ return "var(--chrome-danger)";
1038
+ default:
1039
+ return "var(--muted)";
1040
+ }
1041
+ }
1042
+ function CreditGauge({ channel, className }) {
1043
+ const ratio = creditGaugeRatio(channel);
1044
+ return /* @__PURE__ */ jsxRuntime.jsx(
1045
+ "div",
1046
+ {
1047
+ role: "meter",
1048
+ "aria-label": `${creditStateWord(channel?.state)} level`,
1049
+ "aria-valuemin": 0,
1050
+ "aria-valuemax": 100,
1051
+ "aria-valuenow": ratio === null ? void 0 : Math.round(ratio * 100),
1052
+ className: ["h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel)]", className ?? ""].join(" "),
1053
+ children: ratio === null ? null : /* @__PURE__ */ jsxRuntime.jsx(
1054
+ "div",
1055
+ {
1056
+ className: "h-full rounded-full transition-[width]",
1057
+ style: { width: `${Math.round(ratio * 100)}%`, backgroundColor: stateColorVar(channel?.state) }
1058
+ }
1059
+ )
1060
+ }
1061
+ );
1062
+ }
1063
+ function formatGrantDate(iso) {
1064
+ if (!iso) return null;
1065
+ const t = Date.parse(iso);
1066
+ if (!Number.isFinite(t)) return null;
1067
+ return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric" });
1068
+ }
1069
+ function CreditRow({
1070
+ type,
903
1071
  channel,
904
1072
  loading,
905
- addHref,
906
- addLabel
1073
+ addHref
907
1074
  }) {
1075
+ const label = CREDIT_TYPE_LABELS[type];
908
1076
  const remaining = channel?.remainingUnits ?? 0;
909
- const total = channel ? Math.max(channel.rawBalanceUnits, channel.remainingUnits) : 0;
910
- const ratio = total > 0 ? Math.min(100, Math.max(0, remaining / total * 100)) : 0;
911
- const low = channel?.lowFuel ?? false;
912
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "w-full", children: [
1077
+ const state = channel?.state ?? null;
1078
+ const unavailable = !loading && !channel;
1079
+ const nextGrant = formatGrantDate(channel?.nextGrantAt);
1080
+ const pools = channel && typeof channel.includedUnits === "number" && typeof channel.purchasedUnits === "number" ? `Included ${channel.includedUnits.toLocaleString()}${nextGrant ? ` (next ${nextGrant})` : ""} \xB7 Purchased ${channel.purchasedUnits.toLocaleString()}` : null;
1081
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "w-full", "data-credit-type": type, children: [
913
1082
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-1 flex items-center justify-between gap-2", children: [
914
1083
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-[color:var(--foreground)]", children: label }),
915
1084
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
916
- loading ? /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3.5 w-14" }) : /* @__PURE__ */ jsxRuntime.jsxs(
917
- "span",
918
- {
919
- className: [
920
- "text-xs font-semibold tabular-nums",
921
- low ? "text-[color:var(--chrome-danger)]" : "text-[color:var(--muted)]"
922
- ].join(" "),
923
- children: [
924
- remaining.toLocaleString(),
925
- " left"
926
- ]
927
- }
928
- ),
929
- addHref ? /* @__PURE__ */ jsxRuntime.jsx(
1085
+ loading ? /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3.5 w-14" }) : unavailable ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-[color:var(--muted)]", children: "Unavailable" }) : /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-xs font-semibold tabular-nums", style: { color: stateColorVar(state) }, children: [
1086
+ remaining.toLocaleString(),
1087
+ " left",
1088
+ state === "not_in_use" ? null : /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "font-normal text-[color:var(--muted)]", children: [
1089
+ " \xB7 ",
1090
+ creditStateWord(state)
1091
+ ] })
1092
+ ] }),
1093
+ addHref && !unavailable ? /* @__PURE__ */ jsxRuntime.jsx(
930
1094
  "a",
931
1095
  {
932
1096
  href: addHref,
933
- "aria-label": addLabel,
1097
+ "aria-label": `Add ${label}`,
934
1098
  className: "flex h-5 w-5 items-center justify-center rounded-full border border-[color:var(--line)] text-[color:var(--muted)] transition hover:border-[color:var(--foreground)] hover:text-[color:var(--foreground)]",
935
1099
  children: /* @__PURE__ */ jsxRuntime.jsx(PlusGlyph, {})
936
1100
  }
937
1101
  ) : null
938
1102
  ] })
939
1103
  ] }),
940
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel)]", children: loading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-full w-full animate-pulse bg-[color:var(--line)]" }) : /* @__PURE__ */ jsxRuntime.jsx(
941
- "div",
942
- {
943
- className: [
944
- "h-full rounded-full transition-[width]",
945
- low ? "bg-[color:var(--chrome-danger)]" : "bg-[color:var(--color-ok,#00783B)]"
946
- ].join(" "),
947
- style: { width: `${ratio}%` }
948
- }
949
- ) })
1104
+ loading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel)]", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-full w-full animate-pulse bg-[color:var(--line)]" }) }) : /* @__PURE__ */ jsxRuntime.jsx(CreditGauge, { channel: unavailable ? null : channel }),
1105
+ !loading && state === "not_in_use" ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-[11px] text-[color:var(--muted)]", children: "Not in use" }) : pools ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-[11px] text-[color:var(--muted)]", children: pools }) : null
950
1106
  ] });
951
1107
  }
952
- var lowFuelHintVisible = false;
953
- var lowFuelHintListeners = /* @__PURE__ */ new Set();
954
- function setLowFuelHintVisible(visible) {
955
- if (lowFuelHintVisible === visible) return;
956
- lowFuelHintVisible = visible;
957
- lowFuelHintListeners.forEach((l) => l());
1108
+ function CreditsTooltip({ fuel }) {
1109
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex w-56 flex-col gap-1.5", "data-testid": "credits-tooltip", children: CREDIT_TYPE_ORDER.map((type) => {
1110
+ const channel = fuel ? fuel[type] ?? null : null;
1111
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex flex-col gap-0.5", children: [
1112
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: creditTooltipLine(type, channel) }),
1113
+ /* @__PURE__ */ jsxRuntime.jsx(CreditGauge, { channel, className: "bg-[#FFFFFF]/20" })
1114
+ ] }, type);
1115
+ }) });
958
1116
  }
959
- function subscribeLowFuelHintVisible(onChange) {
960
- lowFuelHintListeners.add(onChange);
961
- return () => lowFuelHintListeners.delete(onChange);
962
- }
963
- function useLowFuelHintVisible() {
964
- return react.useSyncExternalStore(
965
- subscribeLowFuelHintVisible,
966
- () => lowFuelHintVisible,
967
- () => false
968
- );
969
- }
970
- var LOW_FUEL_HINT_CLEARANCE_PX = 96;
971
- function LowFuelHint({ lowFuel, orgId }) {
972
- const [dismissed, setDismissed] = react.useState(() => {
973
- if (typeof window === "undefined") return false;
974
- try {
975
- return window.sessionStorage.getItem(LOW_FUEL_HINT_DISMISSED_KEY) === "1";
976
- } catch {
977
- return false;
978
- }
979
- });
980
- react.useEffect(() => {
981
- if (lowFuel) return;
982
- try {
983
- window.sessionStorage.removeItem(LOW_FUEL_HINT_DISMISSED_KEY);
984
- } catch {
985
- }
986
- }, [lowFuel]);
987
- const panelOpen = useAnyTopBarPanelOpen();
988
- const showing = lowFuel && !dismissed && !panelOpen;
989
- react.useEffect(() => {
990
- setLowFuelHintVisible(showing);
991
- return () => setLowFuelHintVisible(false);
992
- }, [showing]);
993
- if (!showing) return null;
994
- const dismiss = () => {
995
- try {
996
- window.sessionStorage.setItem(LOW_FUEL_HINT_DISMISSED_KEY, "1");
997
- } catch {
998
- }
999
- setDismissed(true);
1000
- };
1001
- return /* @__PURE__ */ jsxRuntime.jsxs(
1002
- "div",
1003
- {
1004
- role: "status",
1005
- className: "absolute right-0 top-[calc(100%+10px)] z-[60] w-60 rounded-lg border border-[color:var(--chrome-danger)] bg-[color:var(--chrome-danger)] px-3 py-2 text-[#FFFFFF] shadow-[var(--shadow-soft)]",
1006
- onClick: (e) => e.stopPropagation(),
1007
- children: [
1008
- /* @__PURE__ */ jsxRuntime.jsx(
1009
- "span",
1010
- {
1011
- "aria-hidden": true,
1012
- className: "absolute -top-[5px] right-[11px] h-2.5 w-2.5 rotate-45 bg-[color:var(--chrome-danger)]"
1013
- }
1014
- ),
1015
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-2", children: [
1016
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1", children: [
1017
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold", children: "You\u2019re low on fuel" }),
1018
- /* @__PURE__ */ jsxRuntime.jsx(
1019
- "a",
1020
- {
1021
- href: withOrgParam(CORE_BILLING_URL, orgId),
1022
- className: "mt-1 inline-block text-sm font-bold underline hover:opacity-90",
1023
- children: "Buy More Fuel"
1024
- }
1025
- )
1026
- ] }),
1027
- /* @__PURE__ */ jsxRuntime.jsx(
1028
- "button",
1029
- {
1030
- type: "button",
1031
- "aria-label": "Dismiss low fuel notice",
1032
- onClick: dismiss,
1033
- className: "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[#FFFFFF]/90 transition hover:bg-[#FFFFFF]/20",
1034
- children: /* @__PURE__ */ jsxRuntime.jsx(XGlyph, {})
1035
- }
1036
- )
1037
- ] })
1038
- ]
1039
- }
1040
- );
1117
+ function channelsOf(fuel) {
1118
+ if (!fuel) return {};
1119
+ return { signal: fuel.signal, broadcast: fuel.broadcast, ai: fuel.ai, enhancement: fuel.enhancement };
1041
1120
  }
1042
1121
  function FuelAction({ orgId }) {
1043
1122
  const { dedupedFetch } = useNavChrome("FuelAction");
@@ -1069,50 +1148,25 @@ function FuelAction({ orgId }) {
1069
1148
  cancelled = true;
1070
1149
  };
1071
1150
  }, [dedupedFetch, orgId]);
1072
- const low = !!fuel && (fuel.signal.lowFuel || fuel.broadcast.lowFuel);
1073
- const lowFuelHintUp = useLowFuelHintVisible();
1074
- return (
1075
- // The nudge anchors to the trigger, so it sits OUTSIDE the popover panel in
1076
- // its own relative wrapper (same shape the VRM widget used).
1077
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-flex", children: [
1078
- /* @__PURE__ */ jsxRuntime.jsx(
1079
- TopBarPopover,
1080
- {
1081
- label: "Communications Fuel",
1082
- description: "Remaining SMS and email sending balance for the active organization.",
1083
- slot: "comms-fuel",
1084
- fallbackIcon: BatteryGlyph,
1085
- tone: low ? "warning" : "default",
1086
- hideTooltip: lowFuelHintUp,
1087
- children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
1088
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Communications Fuel" }),
1089
- error || !loading && !fuel ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Fuel balance is unavailable right now." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
1090
- /* @__PURE__ */ jsxRuntime.jsx(
1091
- FuelRow,
1092
- {
1093
- label: "SMS Signal",
1094
- channel: fuel?.signal ?? null,
1095
- loading,
1096
- addHref: withOrgParam(CORE_BILLING_URL, orgId),
1097
- addLabel: "Add SMS Signal fuel"
1098
- }
1099
- ),
1100
- /* @__PURE__ */ jsxRuntime.jsx(
1101
- FuelRow,
1102
- {
1103
- label: "Email Broadcast",
1104
- channel: fuel?.broadcast ?? null,
1105
- loading,
1106
- addHref: withOrgParam(CORE_BILLING_URL, orgId),
1107
- addLabel: "Add Email Broadcast fuel"
1108
- }
1109
- )
1110
- ] })
1111
- ] })
1112
- }
1113
- ),
1114
- /* @__PURE__ */ jsxRuntime.jsx(LowFuelHint, { lowFuel: low, orgId })
1115
- ] })
1151
+ const channels = channelsOf(fuel);
1152
+ const iconState = creditsIconState(CREDIT_TYPE_ORDER.map((t) => channels[t]));
1153
+ const Glyph = react.useMemo(() => creditsBatteryGlyph(iconState), [iconState]);
1154
+ const addHref = withOrgParam(CORE_BILLING_URL, orgId);
1155
+ return /* @__PURE__ */ jsxRuntime.jsx(
1156
+ TopBarPopover,
1157
+ {
1158
+ label: "Credits",
1159
+ ariaLabel: creditsAriaLabel(channels),
1160
+ tooltip: /* @__PURE__ */ jsxRuntime.jsx(CreditsTooltip, { fuel }),
1161
+ slot: "comms-fuel",
1162
+ fallbackIcon: BatteryGlyph,
1163
+ icon: Glyph,
1164
+ tone: stateTone(iconState),
1165
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
1166
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Credits" }),
1167
+ error || !loading && !fuel ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Credits are unavailable right now." }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-3", children: CREDIT_TYPE_ORDER.map((type) => /* @__PURE__ */ jsxRuntime.jsx(CreditRow, { type, channel: channels[type] ?? null, loading, addHref }, type)) })
1168
+ ] })
1169
+ }
1116
1170
  );
1117
1171
  }
1118
1172
  function notifStateKey(orgId) {
@@ -1193,7 +1247,6 @@ function NewReplyToast({
1193
1247
  const [visible, setVisible] = react.useState(false);
1194
1248
  const onCloseRef = react.useRef(onClose);
1195
1249
  onCloseRef.current = onClose;
1196
- const lowFuelHintVisible2 = useLowFuelHintVisible();
1197
1250
  react.useEffect(() => {
1198
1251
  setVisible(true);
1199
1252
  const fadeAt = Math.max(0, ttlMs - 450);
@@ -1223,7 +1276,6 @@ function NewReplyToast({
1223
1276
  },
1224
1277
  className: "absolute right-[-6px] top-[calc(100%+12px)] z-50 min-w-[240px] max-w-[320px] cursor-pointer rounded-xl bg-[#161616] px-4 py-3 text-[#FFFFFF] shadow-[var(--shadow-soft)]",
1225
1278
  style: {
1226
- top: lowFuelHintVisible2 ? `calc(100% + 12px + ${LOW_FUEL_HINT_CLEARANCE_PX}px)` : void 0,
1227
1279
  opacity: visible ? 1 : 0,
1228
1280
  transform: visible ? "translateY(0px)" : "translateY(-4px)",
1229
1281
  transition: "opacity 250ms ease, transform 250ms ease"
@@ -4824,7 +4876,7 @@ function FrameBody({
4824
4876
  {
4825
4877
  visible: bootVisible,
4826
4878
  bootProgress,
4827
- syntheticReady: appReady ?? chromeResolved,
4879
+ syntheticReady: (appReady ?? chromeResolved) && !chrome?.orgPending,
4828
4880
  onExitComplete: () => setBootVisible(false)
4829
4881
  },
4830
4882
  bootGeneration
@@ -4914,6 +4966,18 @@ function FrameBody({
4914
4966
  }
4915
4967
  var ORG_STORAGE_KEY = "vrm.activeOrg";
4916
4968
  var MODE_STORAGE_KEY = "vrm.activeMode";
4969
+ var orgSwitchLanding = {
4970
+ href(orgId) {
4971
+ return modeLandingHref("relationship", {
4972
+ orgId,
4973
+ hostname: typeof window === "undefined" ? null : window.location.hostname
4974
+ });
4975
+ },
4976
+ navigate(href) {
4977
+ window.location.assign(href);
4978
+ }
4979
+ };
4980
+ var ORG_SWITCH_REEXCHANGE_BUDGET_MS = 4e3;
4917
4981
  var ChromeStateContext = react.createContext(null);
4918
4982
  function useChromeState() {
4919
4983
  return react.useContext(ChromeStateContext);
@@ -4929,6 +4993,7 @@ function TokenChromeStateProvider({
4929
4993
  const [pendingOrg, setPendingOrg] = react.useState(null);
4930
4994
  const tokenOrgRef = react.useRef(tokenOrg);
4931
4995
  tokenOrgRef.current = tokenOrg;
4996
+ const leavingRef = react.useRef(false);
4932
4997
  react.useEffect(() => {
4933
4998
  setNavDisplayState(resolveNavDisplay());
4934
4999
  }, []);
@@ -4969,6 +5034,7 @@ function TokenChromeStateProvider({
4969
5034
  }, [overrideOrg, tokenOrg.orgId]);
4970
5035
  react.useEffect(() => {
4971
5036
  if (!pendingOrg || tokenOrg.orgId !== pendingOrg) return;
5037
+ if (leavingRef.current) return;
4972
5038
  setPendingOrg(null);
4973
5039
  try {
4974
5040
  const next = syncOrgIdInUrl(window.location.href, pendingOrg);
@@ -4991,8 +5057,16 @@ function TokenChromeStateProvider({
4991
5057
  window.history.replaceState(window.history.state, "", url.toString());
4992
5058
  } catch {
4993
5059
  }
4994
- await tokenOrgRef.current.onReexchange(claims.orgId, { perTab: false });
5060
+ leavingRef.current = true;
5061
+ await Promise.race([
5062
+ Promise.resolve(tokenOrgRef.current.onReexchange(claims.orgId, { perTab: false })).catch(() => {
5063
+ }),
5064
+ new Promise((resolve) => setTimeout(resolve, ORG_SWITCH_REEXCHANGE_BUDGET_MS))
5065
+ ]);
5066
+ const href = orgSwitchLanding.href(claims.orgId);
5067
+ if (href) orgSwitchLanding.navigate(href);
4995
5068
  } catch (err) {
5069
+ leavingRef.current = false;
4996
5070
  setPendingOrg(null);
4997
5071
  tokenOrgRef.current.onSwitchError?.(err);
4998
5072
  }
@@ -5057,10 +5131,13 @@ function ChromeStateProvider({
5057
5131
  const navReconciled = react.useRef(false);
5058
5132
  const orgReconciled = react.useRef(false);
5059
5133
  const hadBootSelector = react.useRef(false);
5134
+ const bootUrlTail = react.useRef(null);
5060
5135
  react.useEffect(() => {
5061
5136
  try {
5137
+ const urlSelector = readOrgSelectorFromUrl(window.location.search);
5138
+ if (urlSelector && !isOrgId(urlSelector)) bootUrlTail.current = urlSelector;
5062
5139
  const selector = resolveActiveOrg();
5063
- if (selector) hadBootSelector.current = true;
5140
+ if (selector || urlSelector) hadBootSelector.current = true;
5064
5141
  const storedOrg = selector ?? localStorage.getItem(ORG_STORAGE_KEY);
5065
5142
  if (storedOrg) setOrgIdState(storedOrg);
5066
5143
  } catch {
@@ -5097,7 +5174,9 @@ function ChromeStateProvider({
5097
5174
  }, []);
5098
5175
  react.useEffect(() => {
5099
5176
  if (!orgs || orgs.length === 0) return;
5100
- setOrgIdState((prev) => prev && orgs.some((o) => o.id === prev) ? prev : orgs[0].id);
5177
+ const fromUrl = matchOrgSelector(bootUrlTail.current, orgs)?.id ?? null;
5178
+ bootUrlTail.current = null;
5179
+ setOrgIdState((prev) => fromUrl ?? (prev && orgs.some((o) => o.id === prev) ? prev : orgs[0].id));
5101
5180
  }, [orgs]);
5102
5181
  const activeOrgRef = react.useRef(null);
5103
5182
  const memberOrgsRef = react.useRef([]);
@@ -5155,6 +5234,10 @@ function ChromeStateProvider({
5155
5234
  } catch {
5156
5235
  }
5157
5236
  writeActiveOrgCookie(id);
5237
+ if (isOrgId(id)) {
5238
+ const href = orgSwitchLanding.href(id);
5239
+ if (href) orgSwitchLanding.navigate(href);
5240
+ }
5158
5241
  }, []);
5159
5242
  const setNavDisplay = react.useCallback(
5160
5243
  (m) => {
@@ -5221,8 +5304,10 @@ function ChromeStateProvider({
5221
5304
  }
5222
5305
  try {
5223
5306
  const url = new URL(window.location.href);
5224
- if (url.searchParams.get(ACTIVE_ORG_QUERY_PARAM) !== activeOrgId) {
5225
- url.searchParams.set(ACTIVE_ORG_QUERY_PARAM, activeOrgId);
5307
+ const tail = orgIdTail(activeOrgId);
5308
+ if (url.searchParams.has(ACTIVE_ORG_QUERY_PARAM) || url.searchParams.get(ORG_ID_QUERY_PARAM) !== tail) {
5309
+ url.searchParams.delete(ACTIVE_ORG_QUERY_PARAM);
5310
+ url.searchParams.set(ORG_ID_QUERY_PARAM, tail);
5226
5311
  window.history.replaceState(window.history.state, "", url.toString());
5227
5312
  }
5228
5313
  } catch {
@@ -5584,6 +5669,7 @@ exports.deriveBreadcrumbs = deriveBreadcrumbs;
5584
5669
  exports.footerLinks = footerLinks;
5585
5670
  exports.looksLikeEmail = looksLikeEmail;
5586
5671
  exports.modeLandingHref = modeLandingHref;
5672
+ exports.orgSwitchLanding = orgSwitchLanding;
5587
5673
  exports.resolveIdentity = resolveIdentity;
5588
5674
  exports.standardNavIcons = standardNavIcons;
5589
5675
  exports.useChromeFooter = useChromeFooter;