@the-portland-company/shell 0.81.2 → 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.
@@ -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"
@@ -4614,8 +4666,10 @@ var POLITOGY_APPS = {
4614
4666
  canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign", grants: ["title"] },
4615
4667
  signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign", grants: ["title"] }
4616
4668
  };
4617
- var DEFAULT_SEARCH_PLACEHOLDER = {
4618
- contacts: "Search contacts, segments, forms"
4669
+ var MODE_SEARCH_PLACEHOLDER = {
4670
+ relationship: "Search contacts, Tribes, forms",
4671
+ campaign: "Search voters, walk lists, polls",
4672
+ petition: "Search petitions, sheets, circulators"
4619
4673
  };
4620
4674
  function PolitogyAppFrame({
4621
4675
  app,
@@ -4822,7 +4876,7 @@ function FrameBody({
4822
4876
  {
4823
4877
  visible: bootVisible,
4824
4878
  bootProgress,
4825
- syntheticReady: appReady ?? chromeResolved,
4879
+ syntheticReady: (appReady ?? chromeResolved) && !chrome?.orgPending,
4826
4880
  onExitComplete: () => setBootVisible(false)
4827
4881
  },
4828
4882
  bootGeneration
@@ -4893,7 +4947,9 @@ function FrameBody({
4893
4947
  // search is the only thing on the left end of the bar. No app wires
4894
4948
  // search yet, so this stays decorative (`NavTopBarProps.search` — "an
4895
4949
  // app that has not wired search yet still gets the correct chrome").
4896
- search: { placeholder: data?.searchPlaceholder ?? DEFAULT_SEARCH_PLACEHOLDER[app] ?? "Search" },
4950
+ search: {
4951
+ placeholder: data?.searchPlaceholder ?? MODE_SEARCH_PLACEHOLDER[appMode ?? mode.id ?? "relationship"] ?? "Search"
4952
+ },
4897
4953
  // No Build notes control (Reference/UI-Platform, decided 2026-10-03).
4898
4954
  // The action cluster is IDENTICAL everywhere — core included. Page
4899
4955
  // actions sit BEFORE it, never inside it, so the Alerts bell keeps its
@@ -4910,6 +4966,18 @@ function FrameBody({
4910
4966
  }
4911
4967
  var ORG_STORAGE_KEY = "vrm.activeOrg";
4912
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;
4913
4981
  var ChromeStateContext = react.createContext(null);
4914
4982
  function useChromeState() {
4915
4983
  return react.useContext(ChromeStateContext);
@@ -4925,6 +4993,7 @@ function TokenChromeStateProvider({
4925
4993
  const [pendingOrg, setPendingOrg] = react.useState(null);
4926
4994
  const tokenOrgRef = react.useRef(tokenOrg);
4927
4995
  tokenOrgRef.current = tokenOrg;
4996
+ const leavingRef = react.useRef(false);
4928
4997
  react.useEffect(() => {
4929
4998
  setNavDisplayState(resolveNavDisplay());
4930
4999
  }, []);
@@ -4965,6 +5034,7 @@ function TokenChromeStateProvider({
4965
5034
  }, [overrideOrg, tokenOrg.orgId]);
4966
5035
  react.useEffect(() => {
4967
5036
  if (!pendingOrg || tokenOrg.orgId !== pendingOrg) return;
5037
+ if (leavingRef.current) return;
4968
5038
  setPendingOrg(null);
4969
5039
  try {
4970
5040
  const next = syncOrgIdInUrl(window.location.href, pendingOrg);
@@ -4987,8 +5057,16 @@ function TokenChromeStateProvider({
4987
5057
  window.history.replaceState(window.history.state, "", url.toString());
4988
5058
  } catch {
4989
5059
  }
4990
- 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);
4991
5068
  } catch (err) {
5069
+ leavingRef.current = false;
4992
5070
  setPendingOrg(null);
4993
5071
  tokenOrgRef.current.onSwitchError?.(err);
4994
5072
  }
@@ -5053,10 +5131,13 @@ function ChromeStateProvider({
5053
5131
  const navReconciled = react.useRef(false);
5054
5132
  const orgReconciled = react.useRef(false);
5055
5133
  const hadBootSelector = react.useRef(false);
5134
+ const bootUrlTail = react.useRef(null);
5056
5135
  react.useEffect(() => {
5057
5136
  try {
5137
+ const urlSelector = readOrgSelectorFromUrl(window.location.search);
5138
+ if (urlSelector && !isOrgId(urlSelector)) bootUrlTail.current = urlSelector;
5058
5139
  const selector = resolveActiveOrg();
5059
- if (selector) hadBootSelector.current = true;
5140
+ if (selector || urlSelector) hadBootSelector.current = true;
5060
5141
  const storedOrg = selector ?? localStorage.getItem(ORG_STORAGE_KEY);
5061
5142
  if (storedOrg) setOrgIdState(storedOrg);
5062
5143
  } catch {
@@ -5093,7 +5174,9 @@ function ChromeStateProvider({
5093
5174
  }, []);
5094
5175
  react.useEffect(() => {
5095
5176
  if (!orgs || orgs.length === 0) return;
5096
- 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));
5097
5180
  }, [orgs]);
5098
5181
  const activeOrgRef = react.useRef(null);
5099
5182
  const memberOrgsRef = react.useRef([]);
@@ -5151,6 +5234,10 @@ function ChromeStateProvider({
5151
5234
  } catch {
5152
5235
  }
5153
5236
  writeActiveOrgCookie(id);
5237
+ if (isOrgId(id)) {
5238
+ const href = orgSwitchLanding.href(id);
5239
+ if (href) orgSwitchLanding.navigate(href);
5240
+ }
5154
5241
  }, []);
5155
5242
  const setNavDisplay = react.useCallback(
5156
5243
  (m) => {
@@ -5217,8 +5304,10 @@ function ChromeStateProvider({
5217
5304
  }
5218
5305
  try {
5219
5306
  const url = new URL(window.location.href);
5220
- if (url.searchParams.get(ACTIVE_ORG_QUERY_PARAM) !== activeOrgId) {
5221
- 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);
5222
5311
  window.history.replaceState(window.history.state, "", url.toString());
5223
5312
  }
5224
5313
  } catch {
@@ -5539,6 +5628,7 @@ function useChromeMode(arg = {}) {
5539
5628
  const selected = selectedModeId ? options.find((m) => m.id === selectedModeId) ?? null : null;
5540
5629
  const current = pinned ?? selected ?? options[0] ?? MODE_DEFS.find((m) => m.id === fallbackModeId) ?? MODE_DEFS[0];
5541
5630
  return {
5631
+ id: current.id,
5542
5632
  label: current.label,
5543
5633
  icon: current.icon,
5544
5634
  // Left-Navigation §7 / Reference/UI-Platform: always the row of three icon
@@ -5571,6 +5661,7 @@ function useChromeMode(arg = {}) {
5571
5661
 
5572
5662
  exports.ChromeTopBarActions = ChromeTopBarActions;
5573
5663
  exports.LoadingScreen = LoadingScreen;
5664
+ exports.MODE_SEARCH_PLACEHOLDER = MODE_SEARCH_PLACEHOLDER;
5574
5665
  exports.POLITOGY_APPS = POLITOGY_APPS;
5575
5666
  exports.PolitogyAppChrome = PolitogyAppChrome;
5576
5667
  exports.PolitogyAppFrame = PolitogyAppFrame;
@@ -5578,6 +5669,7 @@ exports.deriveBreadcrumbs = deriveBreadcrumbs;
5578
5669
  exports.footerLinks = footerLinks;
5579
5670
  exports.looksLikeEmail = looksLikeEmail;
5580
5671
  exports.modeLandingHref = modeLandingHref;
5672
+ exports.orgSwitchLanding = orgSwitchLanding;
5581
5673
  exports.resolveIdentity = resolveIdentity;
5582
5674
  exports.standardNavIcons = standardNavIcons;
5583
5675
  exports.useChromeFooter = useChromeFooter;