@the-portland-company/shell 0.82.0 → 0.85.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.
@@ -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"
@@ -4541,10 +4593,19 @@ var CORE_APP = "vrm";
4541
4593
  var DEFAULT_SIGN_OUT_URL = "/auth/signout";
4542
4594
  var POLITOGY_APPS = {
4543
4595
  vrm: { title: "Politogy VRM", origin: "https://app.politogyvrm.com", storagePrefix: "vrm" },
4596
+ // Contacts, Forms, Surveys and CTAs are Relationship rows only (Specs/
4597
+ // Left-Navigation §9.1; Campaign has none of them, §9.2), so they are
4598
+ // PINNED to Relationship like the Dashboard below. Left unscoped, the mode
4599
+ // switcher and rail colour took the user's last mode choice while the rows
4600
+ // stayed Relationship: after picking Campaign, Contacts drew the red
4601
+ // "Campaign mode" rail around Relationship rows, even after a reload
4602
+ // (measured 2026-10-16 on We need Steve; §11.1 "the rail never shows a mix
4603
+ // of two modes").
4544
4604
  contacts: {
4545
4605
  title: "Contacts",
4546
4606
  origin: "https://contacts.politogyvrm.com",
4547
4607
  storagePrefix: "contacts",
4608
+ mode: "relationship",
4548
4609
  pageTitle: false
4549
4610
  },
4550
4611
  // 'title' for forms/surveys/polls/ctas for the same reason as petitions
@@ -4553,11 +4614,11 @@ var POLITOGY_APPS = {
4553
4614
  // page header falls back to the static registry title forever, so the
4554
4615
  // designed "<mode> mode" eyebrow + H2 + subtitle block never gets the real
4555
4616
  // per-page title an app passes it (UI-Platform → Page header).
4556
- forms: { title: "Forms", origin: "https://forms.politogyvrm.com", storagePrefix: "forms", grants: ["title"] },
4557
- surveys: { title: "Surveys", origin: "https://surveys.politogyvrm.com", storagePrefix: "surveys", grants: ["title"] },
4617
+ forms: { title: "Forms", origin: "https://forms.politogyvrm.com", storagePrefix: "forms", mode: "relationship", grants: ["title"] },
4618
+ surveys: { title: "Surveys", origin: "https://surveys.politogyvrm.com", storagePrefix: "surveys", mode: "relationship", grants: ["title"] },
4558
4619
  // Specs/Left-Navigation §9.2: Polling is a Campaign row (Research).
4559
4620
  polls: { title: "Polls", origin: "https://polls.politogyvrm.com", storagePrefix: "polls", mode: "campaign", grants: ["title"] },
4560
- ctas: { title: "CTAs", origin: "https://ctas.politogyvrm.com", storagePrefix: "ctas", grants: ["title"] },
4621
+ ctas: { title: "CTAs", origin: "https://ctas.politogyvrm.com", storagePrefix: "ctas", mode: "relationship", grants: ["title"] },
4561
4622
  // V2 apps that replace retired app.politogyvrm.com pages (Reference/
4562
4623
  // UI-Platform v42 "Retirement of app.politogyvrm.com"). Dashboard is the
4563
4624
  // Relationship Dashboard, the Relationship Mode landing page, and nothing
@@ -4781,7 +4842,7 @@ function FrameBody({
4781
4842
  const coreOrgs = chrome?.orgs ?? [];
4782
4843
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
4783
4844
  const navDisplay = chrome?.navDisplay ?? "both";
4784
- const navMode = appMode ?? "relationship";
4845
+ const navMode = appMode ?? (app !== CORE_APP && isStandardNavMode(mode.id) ? mode.id : "relationship");
4785
4846
  const activeOrgId = chrome?.orgId ?? null;
4786
4847
  const derivedNavItems = react.useMemo(
4787
4848
  () => standardPolitogyNav({
@@ -4824,7 +4885,7 @@ function FrameBody({
4824
4885
  {
4825
4886
  visible: bootVisible,
4826
4887
  bootProgress,
4827
- syntheticReady: appReady ?? chromeResolved,
4888
+ syntheticReady: (appReady ?? chromeResolved) && !chrome?.orgPending && (appMode !== void 0 || mode.resolved !== false),
4828
4889
  onExitComplete: () => setBootVisible(false)
4829
4890
  },
4830
4891
  bootGeneration
@@ -4914,6 +4975,18 @@ function FrameBody({
4914
4975
  }
4915
4976
  var ORG_STORAGE_KEY = "vrm.activeOrg";
4916
4977
  var MODE_STORAGE_KEY = "vrm.activeMode";
4978
+ var orgSwitchLanding = {
4979
+ href(orgId) {
4980
+ return modeLandingHref("relationship", {
4981
+ orgId,
4982
+ hostname: typeof window === "undefined" ? null : window.location.hostname
4983
+ });
4984
+ },
4985
+ navigate(href) {
4986
+ window.location.assign(href);
4987
+ }
4988
+ };
4989
+ var ORG_SWITCH_REEXCHANGE_BUDGET_MS = 4e3;
4917
4990
  var ChromeStateContext = react.createContext(null);
4918
4991
  function useChromeState() {
4919
4992
  return react.useContext(ChromeStateContext);
@@ -4929,6 +5002,7 @@ function TokenChromeStateProvider({
4929
5002
  const [pendingOrg, setPendingOrg] = react.useState(null);
4930
5003
  const tokenOrgRef = react.useRef(tokenOrg);
4931
5004
  tokenOrgRef.current = tokenOrg;
5005
+ const leavingRef = react.useRef(false);
4932
5006
  react.useEffect(() => {
4933
5007
  setNavDisplayState(resolveNavDisplay());
4934
5008
  }, []);
@@ -4969,6 +5043,7 @@ function TokenChromeStateProvider({
4969
5043
  }, [overrideOrg, tokenOrg.orgId]);
4970
5044
  react.useEffect(() => {
4971
5045
  if (!pendingOrg || tokenOrg.orgId !== pendingOrg) return;
5046
+ if (leavingRef.current) return;
4972
5047
  setPendingOrg(null);
4973
5048
  try {
4974
5049
  const next = syncOrgIdInUrl(window.location.href, pendingOrg);
@@ -4991,8 +5066,16 @@ function TokenChromeStateProvider({
4991
5066
  window.history.replaceState(window.history.state, "", url.toString());
4992
5067
  } catch {
4993
5068
  }
4994
- await tokenOrgRef.current.onReexchange(claims.orgId, { perTab: false });
5069
+ leavingRef.current = true;
5070
+ await Promise.race([
5071
+ Promise.resolve(tokenOrgRef.current.onReexchange(claims.orgId, { perTab: false })).catch(() => {
5072
+ }),
5073
+ new Promise((resolve) => setTimeout(resolve, ORG_SWITCH_REEXCHANGE_BUDGET_MS))
5074
+ ]);
5075
+ const href = orgSwitchLanding.href(claims.orgId);
5076
+ if (href) orgSwitchLanding.navigate(href);
4995
5077
  } catch (err) {
5078
+ leavingRef.current = false;
4996
5079
  setPendingOrg(null);
4997
5080
  tokenOrgRef.current.onSwitchError?.(err);
4998
5081
  }
@@ -5057,10 +5140,13 @@ function ChromeStateProvider({
5057
5140
  const navReconciled = react.useRef(false);
5058
5141
  const orgReconciled = react.useRef(false);
5059
5142
  const hadBootSelector = react.useRef(false);
5143
+ const bootUrlTail = react.useRef(null);
5060
5144
  react.useEffect(() => {
5061
5145
  try {
5146
+ const urlSelector = readOrgSelectorFromUrl(window.location.search);
5147
+ if (urlSelector && !isOrgId(urlSelector)) bootUrlTail.current = urlSelector;
5062
5148
  const selector = resolveActiveOrg();
5063
- if (selector) hadBootSelector.current = true;
5149
+ if (selector || urlSelector) hadBootSelector.current = true;
5064
5150
  const storedOrg = selector ?? localStorage.getItem(ORG_STORAGE_KEY);
5065
5151
  if (storedOrg) setOrgIdState(storedOrg);
5066
5152
  } catch {
@@ -5097,7 +5183,9 @@ function ChromeStateProvider({
5097
5183
  }, []);
5098
5184
  react.useEffect(() => {
5099
5185
  if (!orgs || orgs.length === 0) return;
5100
- setOrgIdState((prev) => prev && orgs.some((o) => o.id === prev) ? prev : orgs[0].id);
5186
+ const fromUrl = matchOrgSelector(bootUrlTail.current, orgs)?.id ?? null;
5187
+ bootUrlTail.current = null;
5188
+ setOrgIdState((prev) => fromUrl ?? (prev && orgs.some((o) => o.id === prev) ? prev : orgs[0].id));
5101
5189
  }, [orgs]);
5102
5190
  const activeOrgRef = react.useRef(null);
5103
5191
  const memberOrgsRef = react.useRef([]);
@@ -5155,6 +5243,10 @@ function ChromeStateProvider({
5155
5243
  } catch {
5156
5244
  }
5157
5245
  writeActiveOrgCookie(id);
5246
+ if (isOrgId(id)) {
5247
+ const href = orgSwitchLanding.href(id);
5248
+ if (href) orgSwitchLanding.navigate(href);
5249
+ }
5158
5250
  }, []);
5159
5251
  const setNavDisplay = react.useCallback(
5160
5252
  (m) => {
@@ -5221,8 +5313,10 @@ function ChromeStateProvider({
5221
5313
  }
5222
5314
  try {
5223
5315
  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);
5316
+ const tail = orgIdTail(activeOrgId);
5317
+ if (url.searchParams.has(ACTIVE_ORG_QUERY_PARAM) || url.searchParams.get(ORG_ID_QUERY_PARAM) !== tail) {
5318
+ url.searchParams.delete(ACTIVE_ORG_QUERY_PARAM);
5319
+ url.searchParams.set(ORG_ID_QUERY_PARAM, tail);
5226
5320
  window.history.replaceState(window.history.state, "", url.toString());
5227
5321
  }
5228
5322
  } catch {
@@ -5511,6 +5605,7 @@ function useChromeMode(arg = {}) {
5511
5605
  const orgId = ctx?.orgId ?? null;
5512
5606
  const orgPending = ctx?.orgPending ?? false;
5513
5607
  const [entitled, setEntitled] = react.useState(null);
5608
+ const [settledOrgId, setSettledOrgId] = react.useState(null);
5514
5609
  const [selectedModeId, setSelectedModeId] = react.useState(null);
5515
5610
  react.useEffect(() => {
5516
5611
  try {
@@ -5525,6 +5620,7 @@ function useChromeMode(arg = {}) {
5525
5620
  const bootMe = me;
5526
5621
  if (bootMe && bootMe.account === orgId && bootMe.modes !== void 0) {
5527
5622
  setEntitled(bootMe.modes ?? null);
5623
+ setSettledOrgId(orgId);
5528
5624
  return;
5529
5625
  }
5530
5626
  let cancelled = false;
@@ -5532,7 +5628,9 @@ function useChromeMode(arg = {}) {
5532
5628
  if (cancelled) return;
5533
5629
  const modes = data?.modes ?? null;
5534
5630
  setEntitled(modes);
5631
+ setSettledOrgId(orgId);
5535
5632
  }).catch(() => {
5633
+ if (!cancelled) setSettledOrgId(orgId);
5536
5634
  });
5537
5635
  return () => {
5538
5636
  cancelled = true;
@@ -5546,6 +5644,9 @@ function useChromeMode(arg = {}) {
5546
5644
  id: current.id,
5547
5645
  label: current.label,
5548
5646
  icon: current.icon,
5647
+ // A registry pin is final at once; otherwise the mode is final when the
5648
+ // active org's Modes are in (or nothing can be read: no chrome or no org).
5649
+ resolved: Boolean(pinned) || !dedupedFetch || !orgId || settledOrgId === orgId && !orgPending,
5549
5650
  // Left-Navigation §7 / Reference/UI-Platform: always the row of three icon
5550
5651
  // buttons. A mode the organization does not hold is shown dimmed and not
5551
5652
  // selectable, never hidden. Before entitlements land only the current mode
@@ -5584,6 +5685,7 @@ exports.deriveBreadcrumbs = deriveBreadcrumbs;
5584
5685
  exports.footerLinks = footerLinks;
5585
5686
  exports.looksLikeEmail = looksLikeEmail;
5586
5687
  exports.modeLandingHref = modeLandingHref;
5688
+ exports.orgSwitchLanding = orgSwitchLanding;
5587
5689
  exports.resolveIdentity = resolveIdentity;
5588
5690
  exports.standardNavIcons = standardNavIcons;
5589
5691
  exports.useChromeFooter = useChromeFooter;