@the-portland-company/shell 0.54.0 → 0.56.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,57 @@
1
+ ## 0.56.0 — 2026-09-30
2
+
3
+ ### Fixed — mode continuity for dual-mode apps (a2p)
4
+
5
+ a2p (Specs/A2P) is the SMS/MMS delivery engine for BOTH Relationship Mode and
6
+ Campaign Mode, but it was registered `mode: 'campaign'` only, so the
7
+ Relationship tree's "SMS/MMS Blast" row sent users into a2p and the whole left
8
+ nav flipped to the Campaign tree + "Campaign Mode" pill — a jarring identity
9
+ switch for a user who never left Relationship Mode.
10
+
11
+ `POLITOGY_APPS[app]` now supports an optional `dual: { modes, defaultMode }`.
12
+ a2p declares `dual: { modes: ['relationship', 'campaign'], defaultMode:
13
+ 'campaign' }`; its EFFECTIVE mode is resolved per-visit by the new
14
+ `resolveActiveMode` (`src/mode-preference.ts`): `?mode=` query param → the new
15
+ cross-subdomain `politogy_active_mode` cookie → the app's own default. Every
16
+ pinned app and core writes that cookie with its own mode, so arriving at a2p
17
+ after using Relationship Mode elsewhere resolves to Relationship even without
18
+ an explicit `?mode=` link. The Relationship tree's "SMS/MMS Blast" row now
19
+ links straight to a2p with `?mode=relationship` (the dead
20
+ `/relationship/sms-mms-blast/*` core routes are removed); the Campaign tree's
21
+ existing A2P row is unchanged.
22
+
23
+ ## 0.55.0 — 2026-09-30
24
+
25
+ ### Fixed — org pill body no longer takes the Mode accent (`./native`)
26
+
27
+ Reference/UI-Platform ("Left bar" / "Per-mode branding") is explicit: the org
28
+ pill sits on a **neutral surface** — only the circular org badge carries a
29
+ brand or Mode color, never the pill body. The `./native` `NavSidebar`'s org
30
+ pill (both the disabled single-org button and the multi-org `<details>`
31
+ selector) resolved `var(--mode-accent, var(--panel-elevated))` as its own
32
+ background and `var(--mode-on-accent, var(--foreground))` as its text color —
33
+ so a Mode with its own accent (Campaign's `#8F1F2A`) painted the **whole
34
+ pill** solid red instead of just the badge. Reported live as "V1 styling" on
35
+ a2p.politogyvrm.com (shell 0.46.0, still present through 0.54.0). Both spots
36
+ now render the neutral `--panel-elevated` / `--line` / `--foreground` chain
37
+ unconditionally; the badge (unchanged) still resolves the org's brand color
38
+ or, absent one, the chrome/Mode accent.
39
+
40
+ ### Fixed — mode pill resolves the semantic Mode accent, not the flat global one
41
+
42
+ The mode pill (both the disabled single-mode button and the multi-mode
43
+ selector) was hardcoded to `var(--color-accent)` / `text-white`, so a Mode
44
+ that declares its own `chrome.accent` (Reference/UI-Platform → "Per-mode
45
+ branding") never reached this pill — only the rail background did. It now
46
+ resolves `var(--mode-accent, var(--color-accent))` with
47
+ `var(--mode-on-accent, white)`, matching the org pill's badge and the active
48
+ nav row's existing pattern.
49
+
50
+ ### Why
51
+
52
+ Live audit against Reference/UI-Platform + the shared Relationship Mode UI
53
+ design template (orchestrator rail-parity sweep, 2026-09-30).
54
+
1
55
  ## 0.53.0 — 2026-09-29
2
56
 
3
57
  ### Added — Loading Screen boot surface (Specs/Loading-Screen)
@@ -1494,11 +1494,6 @@ var APP_EMAIL_CHILDREN = [
1494
1494
  { label: "Deliverability", icon: "activity", href: `${EMAIL_APP}/deliverability` }
1495
1495
  ];
1496
1496
  var EMAIL_V1_LEGACY = { label: "Email V1 (legacy)", icon: "clock", path: "/relationship/email/dashboard" };
1497
- var SMS_CHILDREN = [
1498
- { label: "Dashboard", icon: "grid", path: "/relationship/sms-mms-blast/dashboard" },
1499
- { label: "Compose & Send", icon: "send", path: "/relationship/sms-mms-blast/compose-and-send" },
1500
- { label: "Conversations", icon: "messageSquare", path: "/relationship/sms-mms-blast/conversations" }
1501
- ];
1502
1497
  var CTA_CHILDREN = [
1503
1498
  { label: "Dashboard", icon: "grid", path: "/relationship/cta-database/dashboard" },
1504
1499
  { label: "Create & Edit", icon: "edit", path: "/relationship/cta-creator/create-and-edit" },
@@ -1579,6 +1574,14 @@ function standardPolitogyNav(opts) {
1579
1574
  href: withOrg(abs(c.path)),
1580
1575
  children: c.children ? leaves(c.children) : void 0
1581
1576
  }));
1577
+ const appLeaves = (origin, children, modeParam) => {
1578
+ const base2 = origin.replace(/\/+$/, "");
1579
+ return children.map((c) => {
1580
+ const url = c.path ? `${base2}/${c.path}` : `${base2}/`;
1581
+ const withMode = modeParam ? `${url}${url.includes("?") ? "&" : "?"}mode=${modeParam}` : url;
1582
+ return { label: c.label, icon: icon(c.icon), href: withOrg(withMode) };
1583
+ });
1584
+ };
1582
1585
  const buildRelationship = () => {
1583
1586
  const contacts = opts.contactsItem ? {
1584
1587
  label: "Contacts",
@@ -1607,11 +1610,17 @@ function standardPolitogyNav(opts) {
1607
1610
  ...leaves([EMAIL_V1_LEGACY])
1608
1611
  ]
1609
1612
  },
1613
+ // SMS/MMS Blast IS a2p (Specs/A2P) — the standalone delivery engine
1614
+ // shared with Campaign Mode, not a core-hosted surface (the old
1615
+ // `/relationship/sms-mms-blast/*` core routes are dead; core no longer
1616
+ // hosts them). Every link carries `mode=relationship` so a2p's nav
1617
+ // resolves the Relationship tree instead of its own Campaign default
1618
+ // (resolveActiveMode, mode-preference.ts).
1610
1619
  {
1611
1620
  label: "SMS/MMS Blast",
1612
1621
  icon: icon("messageSquare"),
1613
- href: withOrg(abs("/relationship/sms-mms-blast/dashboard")),
1614
- children: leaves(SMS_CHILDREN)
1622
+ href: withOrg(`${A2P_ORIGIN}?mode=relationship`),
1623
+ children: appLeaves(A2P_ORIGIN, APP_A2P_CHILDREN, "relationship")
1615
1624
  },
1616
1625
  // These two carry no top-level href in VRM core (parent row only toggles).
1617
1626
  { label: "Calls to Action", icon: icon("layout"), children: leaves(CTA_CHILDREN) },
@@ -1652,14 +1661,6 @@ function standardPolitogyNav(opts) {
1652
1661
  ...CAMPAIGN_FEATURE_AVAILABILITY,
1653
1662
  ...opts.campaignAvailability
1654
1663
  };
1655
- const appLeaves = (origin, children) => {
1656
- const base2 = origin.replace(/\/+$/, "");
1657
- return children.map((c) => ({
1658
- label: c.label,
1659
- icon: icon(c.icon),
1660
- href: withOrg(c.path ? `${base2}/${c.path}` : `${base2}/`)
1661
- }));
1662
- };
1663
1664
  const rows = [
1664
1665
  { label: "Dashboard", icon: icon("home"), href: withOrg(abs("/campaign/dashboard")) },
1665
1666
  { label: "Voter Contact", icon: icon("target"), href: withOrg(abs("/campaign/campaigns")) },
@@ -1932,7 +1933,7 @@ function NavSidebar({
1932
1933
  ),
1933
1934
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: "Loading organization" })
1934
1935
  ] }) : org.organizations && org.organizations.length > 0 && org.onSwitch ? /* @__PURE__ */ jsxRuntime.jsxs("details", { "data-nav-details": true, className: "group relative", children: [
1935
- /* @__PURE__ */ jsxRuntime.jsxs("summary", { className: "flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md border border-[color:var(--mode-accent,var(--line))] bg-[color:var(--mode-accent,var(--panel-elevated))] px-2.5 py-2 text-left text-[color:var(--mode-on-accent,var(--foreground))] shadow-sm transition hover:bg-[color:var(--mode-accent-strong,var(--panel))] [&::-webkit-details-marker]:hidden", children: [
1936
+ /* @__PURE__ */ jsxRuntime.jsxs("summary", { className: "flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md border border-[color:var(--line)] bg-[color:var(--panel-elevated)] px-2.5 py-2 text-left text-[color:var(--foreground)] shadow-sm transition hover:bg-[color:var(--panel)] [&::-webkit-details-marker]:hidden", children: [
1936
1937
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [
1937
1938
  /* @__PURE__ */ jsxRuntime.jsx(
1938
1939
  "span",
@@ -2021,7 +2022,7 @@ function NavSidebar({
2021
2022
  {
2022
2023
  type: "button",
2023
2024
  onClick: org.onClick,
2024
- className: "flex w-full items-center justify-between gap-2 rounded-md border border-[color:var(--mode-accent,var(--line))] bg-[color:var(--mode-accent,var(--panel-elevated))] px-2.5 py-2 text-left text-[color:var(--mode-on-accent,var(--foreground))] shadow-sm transition hover:bg-[color:var(--mode-accent-strong,var(--panel))]",
2025
+ className: "flex w-full items-center justify-between gap-2 rounded-md border border-[color:var(--line)] bg-[color:var(--panel-elevated)] px-2.5 py-2 text-left text-[color:var(--foreground)] shadow-sm transition hover:bg-[color:var(--panel)]",
2025
2026
  children: [
2026
2027
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [
2027
2028
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2040,7 +2041,7 @@ function NavSidebar({
2040
2041
  )
2041
2042
  ] }) : null,
2042
2043
  mode ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-4", children: mode.options && mode.options.length > 1 && mode.onSelect ? /* @__PURE__ */ jsxRuntime.jsxs("details", { "data-nav-details": true, className: "group relative", children: [
2043
- /* @__PURE__ */ jsxRuntime.jsxs("summary", { className: "flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md bg-[color:var(--color-accent)] px-2.5 py-2 text-white shadow-sm transition hover:opacity-95 [&::-webkit-details-marker]:hidden", children: [
2044
+ /* @__PURE__ */ jsxRuntime.jsxs("summary", { className: "flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md bg-[color:var(--mode-accent,var(--color-accent))] px-2.5 py-2 text-[color:var(--mode-on-accent,white)] shadow-sm transition hover:bg-[color:var(--mode-accent-strong,var(--color-accent))] hover:opacity-95 [&::-webkit-details-marker]:hidden", children: [
2044
2045
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex items-center gap-2.5", children: [
2045
2046
  ModeIcon ? /* @__PURE__ */ jsxRuntime.jsx(ModeIcon, { size: 16, className: "shrink-0" }) : null,
2046
2047
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-semibold tracking-[-0.01em]", children: mode.label })
@@ -2075,7 +2076,7 @@ function NavSidebar({
2075
2076
  "button",
2076
2077
  {
2077
2078
  type: "button",
2078
- className: "flex w-full items-center justify-between gap-2 rounded-md bg-[color:var(--color-accent)] px-2.5 py-2 text-white shadow-sm",
2079
+ className: "flex w-full items-center justify-between gap-2 rounded-md bg-[color:var(--mode-accent,var(--color-accent))] px-2.5 py-2 text-[color:var(--mode-on-accent,white)] shadow-sm",
2079
2080
  children: [
2080
2081
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex items-center gap-2.5", children: [
2081
2082
  ModeIcon ? /* @__PURE__ */ jsxRuntime.jsx(ModeIcon, { size: 16, className: "shrink-0" }) : null,
@@ -3256,6 +3257,58 @@ function watchLogout(onLogout, options = {}) {
3256
3257
  }
3257
3258
  return cleanup;
3258
3259
  }
3260
+
3261
+ // src/mode-preference.ts
3262
+ var ACTIVE_MODE_COOKIE = "politogy_active_mode";
3263
+ var VALID_MODES = ["relationship", "campaign", "petition"];
3264
+ function isStandardNavMode(v) {
3265
+ return typeof v === "string" && VALID_MODES.includes(v);
3266
+ }
3267
+ function readActiveModeCookie() {
3268
+ if (typeof document === "undefined") return null;
3269
+ try {
3270
+ const m = document.cookie.match(
3271
+ new RegExp(`(?:^|;\\s*)${ACTIVE_MODE_COOKIE}=([^;]+)`)
3272
+ );
3273
+ const v = m ? decodeURIComponent(m[1]) : null;
3274
+ return isStandardNavMode(v) ? v : null;
3275
+ } catch {
3276
+ return null;
3277
+ }
3278
+ }
3279
+ function writeActiveModeCookie(mode) {
3280
+ if (typeof document === "undefined" || typeof window === "undefined") return;
3281
+ const host = window.location.hostname;
3282
+ const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
3283
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
3284
+ document.cookie = `${ACTIVE_MODE_COOKIE}=${encodeURIComponent(
3285
+ mode
3286
+ )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`;
3287
+ }
3288
+ function modeFromSearch(search) {
3289
+ if (!search) return null;
3290
+ try {
3291
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
3292
+ return params.get("mode");
3293
+ } catch {
3294
+ return null;
3295
+ }
3296
+ }
3297
+ function resolveActiveMode({
3298
+ search,
3299
+ modes,
3300
+ defaultMode
3301
+ }) {
3302
+ const fromQuery = modeFromSearch(search);
3303
+ if (fromQuery && isStandardNavMode(fromQuery) && modes.includes(fromQuery)) {
3304
+ return fromQuery;
3305
+ }
3306
+ const fromCookie = readActiveModeCookie();
3307
+ if (fromCookie && modes.includes(fromCookie)) {
3308
+ return fromCookie;
3309
+ }
3310
+ return defaultMode;
3311
+ }
3259
3312
  var BOOT_STAGE_TABLE = [
3260
3313
  { id: "s0_session", weight: 5, critical: true, waitsOn: "Politogy ID session resolution" },
3261
3314
  { id: "s1_org", weight: 10, critical: true, waitsOn: "Chrome API /api/chrome/me + orgs" },
@@ -3625,7 +3678,17 @@ var POLITOGY_APPS = {
3625
3678
  // Campaign Mode apps — each scoped to Campaign, so they render the Campaign
3626
3679
  // tree and a static Campaign pill. Not yet deployed; hidden from the Campaign
3627
3680
  // nav by CAMPAIGN_FEATURE_AVAILABILITY until their launch flips the flag.
3628
- a2p: { title: "A2P Messaging", origin: "https://a2p.politogyvrm.com", storagePrefix: "a2p", mode: "campaign" },
3681
+ // Dual-mode: a2p is the SMS/MMS delivery engine for BOTH Relationship Mode
3682
+ // and Campaign Mode (Specs/A2P). `mode: 'campaign'` stays the fallback for
3683
+ // anything reading `meta.mode` directly; the effective mode a user sees is
3684
+ // resolved per-visit (see `dual`, resolveActiveMode).
3685
+ a2p: {
3686
+ title: "A2P Messaging",
3687
+ origin: "https://a2p.politogyvrm.com",
3688
+ storagePrefix: "a2p",
3689
+ mode: "campaign",
3690
+ dual: { modes: ["relationship", "campaign"], defaultMode: "campaign" }
3691
+ },
3629
3692
  phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign" },
3630
3693
  canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign" },
3631
3694
  signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign" }
@@ -3762,9 +3825,19 @@ function FrameBody({
3762
3825
  });
3763
3826
  const userMenu = core?.userMenu ?? derivedUserMenu;
3764
3827
  const footer = useChromeFooter(buildMeta ?? {});
3765
- const appMode = meta.mode;
3828
+ const resolvedDualMode = meta.dual ? resolveActiveMode({
3829
+ search: search ?? (typeof window !== "undefined" ? window.location.search : void 0),
3830
+ modes: meta.dual.modes,
3831
+ defaultMode: meta.dual.defaultMode
3832
+ }) : void 0;
3833
+ const appMode = resolvedDualMode ?? meta.mode;
3766
3834
  const derivedMode = useChromeMode(appMode ? { pinnedModeId: appMode } : void 0);
3767
3835
  const mode = core?.mode ?? derivedMode;
3836
+ const coreLiveMode = app === CORE_APP ? pathname.startsWith("/campaign") ? "campaign" : pathname.startsWith("/relationship") ? "relationship" : void 0 : void 0;
3837
+ const cookieMode = coreLiveMode ?? (meta.dual ? void 0 : meta.mode);
3838
+ react.useEffect(() => {
3839
+ if (cookieMode) writeActiveModeCookie(cookieMode);
3840
+ }, [cookieMode]);
3768
3841
  const coreOrgs = chrome?.orgs ?? [];
3769
3842
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
3770
3843
  const navDisplay = chrome?.navDisplay ?? "both";