@the-portland-company/shell 0.31.0 → 0.32.1

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,40 @@
1
+ ## 0.32.0 — 2026-07-23
2
+
3
+ ### Added — mode-aware navigation (Campaign Mode) with zero new chrome config
4
+
5
+ `standardPolitogyNav` now builds a per-Mode tree. `mode: 'relationship'` (the
6
+ default) is **byte-for-byte** what it produced before — no consumer changes,
7
+ no regression. `mode: 'campaign'` produces the Campaign tree — Dashboard, Voter
8
+ Contact (both core-hosted under `app.politogyvrm.com/campaign/*`), then A2P,
9
+ Phone Banking, Field Canvassing, Sign Mapping (standalone apps), Tribes (the
10
+ Contacts app's `/tribes`) and Polling Engine (the polls app). `mode: 'petition'`
11
+ is a minimal tree pointing at the Petition Mode app.
12
+
13
+ Which tree an app renders is **derived, not passed**: it comes from the app's
14
+ own `mode` in the `POLITOGY_APPS` registry. New rows were added for the four
15
+ Campaign apps — `a2p`, `phonebank`, `canvass`, `signs` — each `mode: 'campaign'`,
16
+ so they render the Campaign tree and a **static Campaign pill** (never a
17
+ Relationship pill). Unscoped apps (forms / surveys / polls / contacts / vrm)
18
+ have no `mode` and render Relationship exactly as before. The zero-drift
19
+ contract holds: an app still passes nothing about its chrome.
20
+
21
+ ### Added — `CAMPAIGN_FEATURE_AVAILABILITY` (staged feature rollout)
22
+
23
+ Campaign features ship incrementally. A disabled feature is **omitted** from the
24
+ nav (not rendered as a dead row); its flag flips to `true` in a later patch
25
+ release as the app launches. Defaults at this version: Polling Engine and Tribes
26
+ on; A2P, Phone Banking, Field Canvassing and Sign Mapping off.
27
+
28
+ ### Changed — Mode switcher targets, and cross-app links carry the active org
29
+
30
+ The Campaign/Petition trees carry the active org on every cross-app link as
31
+ `?org=<last6>` — the same convention the Mode switcher and My Account menu use —
32
+ so the destination app opens on the same org. The Mode switcher's Petition
33
+ destination is corrected to `petition-mode.politogyvrm.com` (the petitions app's
34
+ real production subdomain), and a mode-scoped app can pass `pinnedModeId` to
35
+ `useChromeMode` so its pill shows its own mode while a multi-mode org still gets
36
+ the switcher to leave.
37
+
1
38
  ## 0.31.0 — 2026-07-23
2
39
 
3
40
  ### Fixed — Surveys sub-menu now shows all three items (matches the tab strip)
package/README.md CHANGED
@@ -179,6 +179,32 @@ import '@the-portland-company/shell/css' // view-transitions CSS for
179
179
 
180
180
  ---
181
181
 
182
+ ## Mode-aware navigation (Campaign Mode)
183
+
184
+ `PolitogyAppFrame` and `standardPolitogyNav` render a different left-nav tree per
185
+ operating **Mode** — and an app passes nothing to select it. Which tree an app
186
+ renders is derived from the app's own `mode` in the `POLITOGY_APPS` registry:
187
+
188
+ - **Unscoped apps** (no `mode` — forms / surveys / polls / contacts / vrm) render
189
+ the **Relationship** tree, byte-for-byte as before, with an entitlement-driven
190
+ Mode pill.
191
+ - **Campaign apps** (`a2p`, `phonebank`, `canvass`, `signs`, all `mode: 'campaign'`)
192
+ render the **Campaign** tree (Dashboard, Voter Contact, then the launched
193
+ feature apps) and a **static Campaign pill**.
194
+ - **Petitions** (`mode: 'petition'`) renders a minimal Petition tree.
195
+
196
+ Campaign features ship incrementally. `CAMPAIGN_FEATURE_AVAILABILITY` (exported
197
+ from `@the-portland-company/shell/native`) gates each feature: a disabled one is
198
+ omitted from the nav, and its flag flips to `true` in a patch release as the app
199
+ launches. Defaults: Polling Engine and Tribes on; A2P, Phone Banking, Field
200
+ Canvassing, Sign Mapping off.
201
+
202
+ Cross-app links in the Campaign/Petition trees carry the active org as
203
+ `?org=<last6>` — the same convention the Mode switcher and My Account menu use —
204
+ so the destination app opens on the same org. When the chrome API grants more
205
+ than one Mode (`scope.modes`), the pill becomes a switcher whose selection
206
+ navigates to that Mode's home carrying the active org.
207
+
182
208
  ## Multi-SPA routing on `app.politogy.com`
183
209
 
184
210
  Multiple politogy apps live behind one domain via a Cloudflare Worker edge router (in `packages/edge-router` of the monorepo).
@@ -820,6 +820,21 @@ function useThemePreference() {
820
820
  }
821
821
 
822
822
  // src/native/standardNav.ts
823
+ var CAMPAIGN_FEATURE_AVAILABILITY = {
824
+ a2p: false,
825
+ phonebank: false,
826
+ canvass: false,
827
+ signs: false,
828
+ tribes: true,
829
+ polling: true
830
+ };
831
+ var A2P_ORIGIN = "https://a2p.politogyvrm.com/";
832
+ var PHONEBANK_ORIGIN = "https://phonebank.politogyvrm.com/";
833
+ var CANVASS_ORIGIN = "https://canvass.politogyvrm.com/";
834
+ var SIGNS_ORIGIN = "https://signs.politogyvrm.com/";
835
+ var POLLS_ORIGIN = "https://polls.politogyvrm.com/";
836
+ var CONTACTS_TRIBES = "https://contacts.politogyvrm.com/tribes";
837
+ var PETITION_MODE_ORIGIN = "https://petition-mode.politogyvrm.com/";
823
838
  var CONTACTS_DEFAULT_CHILDREN = [
824
839
  { label: "Dashboard", icon: "grid", path: "/relationship/contacts/dashboard" },
825
840
  { label: "All Contacts", icon: "list", path: "/relationship/contacts/all" },
@@ -889,59 +904,85 @@ function standardPolitogyNav(opts) {
889
904
  href: abs(c.path),
890
905
  children: c.children ? leaves(c.children) : void 0
891
906
  }));
892
- const contacts = opts.contactsItem ? {
893
- label: "Contacts",
894
- icon: icon("users"),
895
- href: opts.contactsItem.href,
896
- children: opts.contactsItem.children
897
- } : {
898
- label: "Contacts",
899
- icon: icon("users"),
900
- href: abs("/relationship/contacts/dashboard"),
901
- children: leaves(CONTACTS_DEFAULT_CHILDREN)
907
+ const orgParam = opts.activeOrgId ? `org=${opts.activeOrgId.slice(-6)}` : "";
908
+ const withOrg = (url) => orgParam ? `${url}${url.includes("?") ? "&" : "?"}${orgParam}` : url;
909
+ const buildRelationship = () => {
910
+ const contacts = opts.contactsItem ? {
911
+ label: "Contacts",
912
+ icon: icon("users"),
913
+ href: opts.contactsItem.href,
914
+ children: opts.contactsItem.children
915
+ } : {
916
+ label: "Contacts",
917
+ icon: icon("users"),
918
+ href: abs("/relationship/contacts/dashboard"),
919
+ children: leaves(CONTACTS_DEFAULT_CHILDREN)
920
+ };
921
+ return [
922
+ { label: "Dashboard", icon: icon("home"), href: abs("/relationship/dashboard") },
923
+ contacts,
924
+ {
925
+ label: "Email Broadcast",
926
+ icon: icon("mail"),
927
+ href: abs("/relationship/email/dashboard"),
928
+ children: leaves(EMAIL_CHILDREN)
929
+ },
930
+ {
931
+ label: "SMS/MMS Blast",
932
+ icon: icon("messageSquare"),
933
+ href: abs("/relationship/sms-mms-blast/dashboard"),
934
+ children: leaves(SMS_CHILDREN)
935
+ },
936
+ // These two carry no top-level href in VRM core (parent row only toggles).
937
+ { label: "Calls to Action", icon: icon("layout"), children: leaves(CTA_CHILDREN) },
938
+ {
939
+ label: "Forms",
940
+ icon: icon("activity"),
941
+ href: "https://forms.politogyvrm.com/",
942
+ children: APP_FORMS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
943
+ },
944
+ {
945
+ label: "Surveys",
946
+ icon: icon("fileText"),
947
+ href: "https://surveys.politogyvrm.com/surveys",
948
+ children: APP_SURVEYS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
949
+ },
950
+ {
951
+ label: "Polls",
952
+ icon: icon("barChart"),
953
+ href: "https://polls.politogyvrm.com/",
954
+ children: APP_POLLS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
955
+ },
956
+ {
957
+ label: "Organization Settings",
958
+ icon: icon("briefcase"),
959
+ href: abs("/organization-settings"),
960
+ children: branches(ORG_SETTINGS_CHILDREN)
961
+ }
962
+ ];
902
963
  };
903
- const items = [
904
- { label: "Dashboard", icon: icon("home"), href: abs("/relationship/dashboard") },
905
- contacts,
906
- {
907
- label: "Email Broadcast",
908
- icon: icon("mail"),
909
- href: abs("/relationship/email/dashboard"),
910
- children: leaves(EMAIL_CHILDREN)
911
- },
912
- {
913
- label: "SMS/MMS Blast",
914
- icon: icon("messageSquare"),
915
- href: abs("/relationship/sms-mms-blast/dashboard"),
916
- children: leaves(SMS_CHILDREN)
917
- },
918
- // These two carry no top-level href in VRM core (parent row only toggles).
919
- { label: "Calls to Action", icon: icon("layout"), children: leaves(CTA_CHILDREN) },
920
- {
921
- label: "Forms",
922
- icon: icon("activity"),
923
- href: "https://forms.politogyvrm.com/",
924
- children: APP_FORMS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
925
- },
926
- {
927
- label: "Surveys",
928
- icon: icon("fileText"),
929
- href: "https://surveys.politogyvrm.com/surveys",
930
- children: APP_SURVEYS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
931
- },
932
- {
933
- label: "Polls",
934
- icon: icon("barChart"),
935
- href: "https://polls.politogyvrm.com/",
936
- children: APP_POLLS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
937
- },
938
- {
939
- label: "Organization Settings",
940
- icon: icon("briefcase"),
941
- href: abs("/organization-settings"),
942
- children: branches(ORG_SETTINGS_CHILDREN)
943
- }
964
+ const buildCampaign = () => {
965
+ const avail = {
966
+ ...CAMPAIGN_FEATURE_AVAILABILITY,
967
+ ...opts.campaignAvailability
968
+ };
969
+ const rows = [
970
+ { label: "Dashboard", icon: icon("home"), href: withOrg(abs("/campaign/dashboard")) },
971
+ { label: "Voter Contact", icon: icon("target"), href: withOrg(abs("/campaign/campaigns")) },
972
+ { label: "A2P", icon: icon("messageSquare"), href: withOrg(A2P_ORIGIN), flag: "a2p" },
973
+ { label: "Phone Banking", icon: icon("phone"), href: withOrg(PHONEBANK_ORIGIN), flag: "phonebank" },
974
+ { label: "Field Canvassing", icon: icon("map"), href: withOrg(CANVASS_ORIGIN), flag: "canvass" },
975
+ { label: "Sign Mapping", icon: icon("mapPin"), href: withOrg(SIGNS_ORIGIN), flag: "signs" },
976
+ { label: "Tribes", icon: icon("users"), href: withOrg(CONTACTS_TRIBES), flag: "tribes" },
977
+ { label: "Polling Engine", icon: icon("barChart"), href: withOrg(POLLS_ORIGIN), flag: "polling" }
978
+ ];
979
+ return rows.filter((r) => !r.flag || avail[r.flag]).map(({ flag: _flag, ...item }) => item);
980
+ };
981
+ const buildPetition = () => [
982
+ { label: "Dashboard", icon: icon("home"), href: withOrg(PETITION_MODE_ORIGIN) }
944
983
  ];
984
+ const mode = opts.mode ?? "relationship";
985
+ const items = mode === "campaign" ? buildCampaign() : mode === "petition" ? buildPetition() : buildRelationship();
945
986
  if (opts.supportItem) {
946
987
  items.push({
947
988
  label: "Support",
@@ -1840,14 +1881,13 @@ function NavBetaBanner({ text, linkLabel, href }) {
1840
1881
  "div",
1841
1882
  {
1842
1883
  role: "note",
1843
- className: "w-full border-y px-4 py-2 text-center text-sm",
1884
+ className: "w-full px-4 py-2 text-center text-sm",
1844
1885
  style: {
1845
- // Warning amber with a darker border + text (HITL 2026-07-23). The
1846
- // --beta-banner-* tokens were never emitted, so the fallbacks are the
1847
- // real values: amber-100 fill, amber-500 border, amber-900 text.
1848
- background: "var(--beta-banner-bg, #FEF3C7)",
1849
- borderColor: "var(--beta-banner-border, #F59E0B)",
1850
- color: "var(--beta-banner-text, #78350F)"
1886
+ // Brand red fill, white text, no borders (HITL 2026-07-24). The
1887
+ // --beta-banner-* tokens still win when the Brand sheet emits them;
1888
+ // otherwise the banner takes the brand accent red.
1889
+ background: "var(--beta-banner-bg, var(--color-accent, #E63946))",
1890
+ color: "var(--beta-banner-text, #FFFFFF)"
1851
1891
  },
1852
1892
  children: [
1853
1893
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: body }),
@@ -1858,7 +1898,7 @@ function NavBetaBanner({ text, linkLabel, href }) {
1858
1898
  target: "_blank",
1859
1899
  rel: "noreferrer",
1860
1900
  className: "font-bold underline",
1861
- style: { color: "var(--beta-banner-text, #78350F)" },
1901
+ style: { color: "var(--beta-banner-text, #FFFFFF)" },
1862
1902
  children: label
1863
1903
  }
1864
1904
  )
@@ -1978,7 +2018,19 @@ var POLITOGY_APPS = {
1978
2018
  forms: { title: "Forms", origin: "https://forms.politogyvrm.com", storagePrefix: "forms" },
1979
2019
  surveys: { title: "Surveys", origin: "https://surveys.politogyvrm.com", storagePrefix: "surveys" },
1980
2020
  polls: { title: "Polls", origin: "https://polls.politogyvrm.com", storagePrefix: "polls" },
1981
- petitions: { title: "Petitions", origin: "https://petitions.politogyvrm.com", storagePrefix: "petitions" }
2021
+ petitions: {
2022
+ title: "Petitions",
2023
+ origin: "https://petition-mode.politogyvrm.com",
2024
+ storagePrefix: "petitions",
2025
+ mode: "petition"
2026
+ },
2027
+ // Campaign Mode apps — each scoped to Campaign, so they render the Campaign
2028
+ // tree and a static Campaign pill. Not yet deployed; hidden from the Campaign
2029
+ // nav by CAMPAIGN_FEATURE_AVAILABILITY until their launch flips the flag.
2030
+ a2p: { title: "A2P Messaging", origin: "https://a2p.politogyvrm.com", storagePrefix: "a2p", mode: "campaign" },
2031
+ phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign" },
2032
+ canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign" },
2033
+ signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign" }
1982
2034
  };
1983
2035
  function PolitogyAppFrame({
1984
2036
  app,
@@ -2042,19 +2094,24 @@ function FrameBody({
2042
2094
  email: identity.email
2043
2095
  });
2044
2096
  const footer = useChromeFooter(buildMeta ?? {});
2045
- const mode = useChromeMode();
2097
+ const appMode = meta.mode;
2098
+ const mode = useChromeMode(appMode ? { pinnedModeId: appMode } : void 0);
2046
2099
  const coreOrgs = chrome?.orgs ?? [];
2047
2100
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
2048
2101
  const navDisplay = chrome?.navDisplay ?? "both";
2102
+ const navMode = appMode ?? "relationship";
2103
+ const activeOrgId = chrome?.orgId ?? null;
2049
2104
  const navItems = react.useMemo(
2050
2105
  () => standardPolitogyNav({
2051
2106
  coreBaseUrl: POLITOGY_APPS.vrm.origin,
2052
2107
  icons: standardNavIcons,
2053
2108
  // An app that OWNS a standard nav entry (Contacts) nests its own routes
2054
2109
  // under it. This is routing data, not chrome configuration.
2055
- contactsItem: data?.contactsItem
2110
+ contactsItem: data?.contactsItem,
2111
+ mode: navMode,
2112
+ activeOrgId
2056
2113
  }),
2057
- [data?.contactsItem]
2114
+ [data?.contactsItem, navMode, activeOrgId]
2058
2115
  );
2059
2116
  const breadcrumbs = react.useMemo(
2060
2117
  () => deriveBreadcrumbs(navItems, pathname, { search, origin: meta.origin }),
@@ -2127,7 +2184,13 @@ var standardNavIcons = {
2127
2184
  settings: fi.FiSettings,
2128
2185
  creditCard: fi.FiCreditCard,
2129
2186
  dollarSign: fi.FiDollarSign,
2130
- share: fi.FiShare2
2187
+ share: fi.FiShare2,
2188
+ // Campaign / Petition mode slots.
2189
+ phone: fi.FiPhone,
2190
+ map: fi.FiMap,
2191
+ mapPin: fi.FiMapPin,
2192
+ target: fi.FiTarget,
2193
+ flag: fi.FiFlag
2131
2194
  };
2132
2195
  var ORG_STORAGE_KEY = "vrm.activeOrg";
2133
2196
  var MODE_STORAGE_KEY = "vrm.activeMode";
@@ -2380,15 +2443,17 @@ var MODE_DEFS = [
2380
2443
  orgQuery: true
2381
2444
  },
2382
2445
  {
2383
- // Petition Mode lives in its own standalone app (HITL 2026-07-23).
2446
+ // Petition Mode lives in its own standalone app on its own subdomain — the
2447
+ // petitions repo deploys production to petition-mode.politogyvrm.com.
2384
2448
  id: "petition",
2385
2449
  label: "Petition Mode",
2386
2450
  icon: fi.FiFlag,
2387
- href: "https://petitions.politogyvrm.com/",
2451
+ href: "https://petition-mode.politogyvrm.com/",
2388
2452
  orgQuery: true
2389
2453
  }
2390
2454
  ];
2391
- function useChromeMode(fallbackModeId = "relationship") {
2455
+ function useChromeMode(arg = {}) {
2456
+ const { fallbackModeId = "relationship", pinnedModeId } = typeof arg === "string" ? { fallbackModeId: arg, pinnedModeId: void 0 } : arg;
2392
2457
  const ctx = react.useContext(ChromeStateContext);
2393
2458
  const nav = useNavChromeFetcher();
2394
2459
  const orgId = ctx?.orgId ?? null;
@@ -2415,8 +2480,9 @@ function useChromeMode(fallbackModeId = "relationship") {
2415
2480
  };
2416
2481
  }, [nav, orgId]);
2417
2482
  const options = MODE_DEFS.filter((m) => entitled?.[m.id]);
2483
+ const pinned = pinnedModeId ? MODE_DEFS.find((m) => m.id === pinnedModeId) ?? null : null;
2418
2484
  const selected = selectedModeId ? options.find((m) => m.id === selectedModeId) ?? null : null;
2419
- const current = selected ?? options[0] ?? MODE_DEFS.find((m) => m.id === fallbackModeId) ?? MODE_DEFS[0];
2485
+ const current = pinned ?? selected ?? options[0] ?? MODE_DEFS.find((m) => m.id === fallbackModeId) ?? MODE_DEFS[0];
2420
2486
  const suffix = orgId ? `?org=${orgId.slice(-6)}` : "";
2421
2487
  return {
2422
2488
  label: current.label,