@the-portland-company/shell 0.99.0 → 0.100.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,20 @@
1
+ ## 0.100.0
2
+
3
+ New pages open at the top (Ben 2026-11-08, clicked "Save as written" and "Go, all Modes" in the scroll-to-top session box; Reference/UI-Platform → URL state → "Scroll position"). Ben: "when clicking from one tab to another … the next page starts out slightly scrolled down. I need every new page to start fully scrolled at the top."
4
+
5
+ - **Cause.** Next's App Router only scrolls when the new page's top edge is off screen, so a page scrolled down a little kept that offset. And most pages and tabs switch by query parameter (`?page=…&tab=…`) on the same path, which nothing reset.
6
+ - **Fix.** `NavAppShell` (so every app on `PolitogyAppFrame`) now scrolls the window to the top, instantly, when the URL moves to a new page: a different path, or a different `mode`, `page` or `tab`. Changing only `section`, `org_id` or a feature parameter (filters, pagination) keeps the place; filling a missing default is not a new page; Back / Forward keep the browser's own position; a `#anchor` URL lands on its anchor. Uses the Navigation API where present, with a `popstate` + render-time check as the fallback, and survives apps that remount their chrome on each page (Tribes, Surveys).
7
+ - New exports on `./native`: `useScrollToTopOnNewPage`, `isNewPage`, `pageIdentityOf`, type `PageIdentity`.
8
+ - Tests: `__tests__/scrollToTop.test.tsx` (new).
9
+
10
+ ## 0.99.1
11
+
12
+ The user card says "Account Manager" for an Account Manager (bug seen live 2026-11-07: benedtl@hotmail.com, `account_manager` in "TEST Campaign A (link test)", a Linked Accounts roster membership, was shown as "Member").
13
+
14
+ - **Role line from the real role.** The card under the name printed the app's own role string, and apps collapse roles to their permission pair (Contacts: admin | member), so `account_manager` came out as "Member". `PolitogyAppFrame` now labels the active organization's raw membership role (the chrome API's `rawRole`; in token mode the `org_role` claim) and falls back to the app's string. Labels (Specs/Organization-Settings §8.4 groups): owner / admin / account_admin → "Admin" and member / field roles → "Member" as before; account_manager → "Account Manager"; viewer → "Viewer".
15
+ - `useChromeState()` gains `activeRawRole`. In controlled mode (Contacts) it comes from the chrome API org list when the app's list has no `rawRole`.
16
+ - Tests: `__tests__/userCardRole.test.tsx` (new).
17
+
1
18
  ## 0.99.0
2
19
 
3
20
  VRM Verified badge and the Contact Card slide-in (Ben 2026-11-06, typed in the UI debugging box; specs Contacts §4.2, §8.6A and VRM-Verified §2 updated with his go).
@@ -422,6 +422,75 @@ function SpeculationRulesScript({ items }) {
422
422
  }, [items, origin]);
423
423
  return null;
424
424
  }
425
+ var PAGE_KEYS = ["mode", "page", "tab"];
426
+ function pageIdentityOf(pathname, search) {
427
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
428
+ const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
429
+ return {
430
+ pathname: path || "/",
431
+ mode: params.get("mode") || null,
432
+ page: params.get("page") || null,
433
+ tab: params.get("tab") || null
434
+ };
435
+ }
436
+ function isNewPage(prev, next) {
437
+ if (prev.pathname !== next.pathname) return true;
438
+ return PAGE_KEYS.some((k) => prev[k] !== null && next[k] !== prev[k]);
439
+ }
440
+ function scrollWindowToTop() {
441
+ if (window.scrollX === 0 && window.scrollY === 0) return;
442
+ try {
443
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
444
+ } catch {
445
+ window.scrollTo(0, 0);
446
+ }
447
+ }
448
+ var lastIdentity = null;
449
+ var listeners = 0;
450
+ var detach = null;
451
+ function currentIdentity() {
452
+ return pageIdentityOf(window.location.pathname, window.location.search);
453
+ }
454
+ function check(traverse = false) {
455
+ const next = currentIdentity();
456
+ const prev = lastIdentity;
457
+ lastIdentity = next;
458
+ if (!prev || traverse) return;
459
+ if (!isNewPage(prev, next)) return;
460
+ if (window.location.hash) return;
461
+ scrollWindowToTop();
462
+ }
463
+ function attach() {
464
+ const nav = window.navigation;
465
+ if (nav && typeof nav.addEventListener === "function") {
466
+ const onEntryChange = (e) => {
467
+ check(e.navigationType === "traverse");
468
+ };
469
+ nav.addEventListener("currententrychange", onEntryChange);
470
+ return () => nav.removeEventListener("currententrychange", onEntryChange);
471
+ }
472
+ const onPopState = () => check(true);
473
+ window.addEventListener("popstate", onPopState);
474
+ return () => window.removeEventListener("popstate", onPopState);
475
+ }
476
+ var useIsoLayoutEffect = typeof window === "undefined" ? react.useEffect : react.useLayoutEffect;
477
+ function useScrollToTopOnNewPage() {
478
+ react.useEffect(() => {
479
+ if (listeners === 0) detach = attach();
480
+ listeners += 1;
481
+ return () => {
482
+ listeners -= 1;
483
+ if (listeners === 0) {
484
+ detach?.();
485
+ detach = null;
486
+ }
487
+ };
488
+ }, []);
489
+ useIsoLayoutEffect(() => {
490
+ if (lastIdentity) check();
491
+ else lastIdentity = currentIdentity();
492
+ });
493
+ }
425
494
 
426
495
  // src/native/credits.ts
427
496
  var CREDIT_TYPE_ORDER = [
@@ -3463,6 +3532,7 @@ function NavAppShell({
3463
3532
  children
3464
3533
  }) {
3465
3534
  useCloseNavDetailsOnOutsideClick();
3535
+ useScrollToTopOnNewPage();
3466
3536
  if (contentWrapper === "bare") {
3467
3537
  warnDeprecated(
3468
3538
  "contentWrapper",
@@ -3673,7 +3743,7 @@ function watchLogout(onLogout, options = {}) {
3673
3743
  cleanup();
3674
3744
  onLogout();
3675
3745
  };
3676
- const check = () => {
3746
+ const check2 = () => {
3677
3747
  const at = readLogoutCookie();
3678
3748
  if (at !== null) fire(at);
3679
3749
  };
@@ -3687,15 +3757,15 @@ function watchLogout(onLogout, options = {}) {
3687
3757
  } catch {
3688
3758
  channel = null;
3689
3759
  }
3690
- const timer = window.setInterval(check, intervalMs);
3691
- window.addEventListener("visibilitychange", check);
3692
- window.addEventListener("focus", check);
3693
- window.addEventListener("pageshow", check);
3760
+ const timer = window.setInterval(check2, intervalMs);
3761
+ window.addEventListener("visibilitychange", check2);
3762
+ window.addEventListener("focus", check2);
3763
+ window.addEventListener("pageshow", check2);
3694
3764
  function cleanup() {
3695
3765
  window.clearInterval(timer);
3696
- window.removeEventListener("visibilitychange", check);
3697
- window.removeEventListener("focus", check);
3698
- window.removeEventListener("pageshow", check);
3766
+ window.removeEventListener("visibilitychange", check2);
3767
+ window.removeEventListener("focus", check2);
3768
+ window.removeEventListener("pageshow", check2);
3699
3769
  try {
3700
3770
  channel?.close();
3701
3771
  } catch {
@@ -4094,6 +4164,34 @@ function LoadingScreen({
4094
4164
  );
4095
4165
  }
4096
4166
 
4167
+ // src/lib/roleLabel.ts
4168
+ var ADMIN_ROLES = /* @__PURE__ */ new Set(["owner", "admin", "account_admin"]);
4169
+ var STAFF_ROLES = /* @__PURE__ */ new Set(["super_admin", "super administrator", "politogy_admin"]);
4170
+ var MANAGER_ROLES = /* @__PURE__ */ new Set(["account_manager", "account manager", "manager", "campaign_manager"]);
4171
+ var FIELD_ROLES = /* @__PURE__ */ new Set([
4172
+ "member",
4173
+ "field_user",
4174
+ "field user",
4175
+ "field",
4176
+ "volunteer",
4177
+ "canvasser",
4178
+ "caller",
4179
+ "staff"
4180
+ ]);
4181
+ var VIEWER_ROLES = /* @__PURE__ */ new Set(["viewer"]);
4182
+ function roleLabel(raw) {
4183
+ const role = (raw ?? "").trim().toLowerCase();
4184
+ if (!role) return null;
4185
+ if (ADMIN_ROLES.has(role) || STAFF_ROLES.has(role)) return "Admin";
4186
+ if (MANAGER_ROLES.has(role)) return "Account Manager";
4187
+ if (FIELD_ROLES.has(role)) return "Member";
4188
+ if (VIEWER_ROLES.has(role)) return "Viewer";
4189
+ return null;
4190
+ }
4191
+ function userCardRole(activeRawRole, appRole) {
4192
+ return roleLabel(activeRawRole) ?? roleLabel(appRole) ?? (appRole?.trim() || null);
4193
+ }
4194
+
4097
4195
  // src/url-state.ts
4098
4196
  var URL_STATE_MODES = ["relationship", "campaign", "petition"];
4099
4197
  var URL_STATE_KEYS = ["mode", "page", "tab", "section", "org_id"];
@@ -4538,7 +4636,9 @@ function FrameBody({
4538
4636
  // The chrome API's avatar wins; the app may supply a fallback chain
4539
4637
  // (e.g. a stored memoji) for users with no uploaded photo.
4540
4638
  avatarUrl: identity.avatarUrl ?? data?.fallbackAvatarUrl ?? null,
4541
- role: session.role
4639
+ // The active org's RAW role wins over the app's collapsed one, so an
4640
+ // Account Manager is not shown as "Member" (Organization-Settings §8.4).
4641
+ role: userCardRole(chrome?.activeRawRole ?? activeCoreOrg?.rawRole, session.role)
4542
4642
  },
4543
4643
  userMenu,
4544
4644
  onSignOut: handleSignOut,
@@ -4726,10 +4826,13 @@ function TokenChromeStateProvider({
4726
4826
  orgs,
4727
4827
  orgsLoading: !tokenOrg.orgs && orgsFetching && !orgsError && !fetchedOrgs,
4728
4828
  orgPending: pendingOrg !== null,
4829
+ // The list's raw role, else the token's `org_role` claim — only while the
4830
+ // token is still for the active org (not mid-switch).
4831
+ activeRawRole: orgs.find((o) => o.id === orgId)?.rawRole ?? (orgId && tokenOrg.orgId === orgId ? tokenOrg.orgRole ?? null : null),
4729
4832
  navDisplay,
4730
4833
  setNavDisplay
4731
4834
  }),
4732
- [orgId, setOrgId, orgs, tokenOrg.orgs, orgsFetching, orgsError, fetchedOrgs, pendingOrg, navDisplay, setNavDisplay]
4835
+ [orgId, setOrgId, orgs, tokenOrg.orgs, tokenOrg.orgId, tokenOrg.orgRole, orgsFetching, orgsError, fetchedOrgs, pendingOrg, navDisplay, setNavDisplay]
4733
4836
  );
4734
4837
  return /* @__PURE__ */ jsxRuntime.jsx(ChromeStateContext.Provider, { value, children });
4735
4838
  }
@@ -4883,6 +4986,9 @@ function ChromeStateProvider({
4883
4986
  })),
4884
4987
  orgsLoading: false,
4885
4988
  orgPending: false,
4989
+ // The app's list collapses roles (Contacts: admin | member); the
4990
+ // chrome API's list carries the raw one.
4991
+ activeRawRole: controlledOrg.orgs?.find((o) => o.id === controlledOrg.orgId)?.rawRole ?? orgs?.find((o) => o.id === controlledOrg.orgId)?.rawRole ?? null,
4886
4992
  navDisplay,
4887
4993
  setNavDisplay
4888
4994
  };
@@ -4903,6 +5009,7 @@ function ChromeStateProvider({
4903
5009
  // falls back to the app label rather than shimmering forever.
4904
5010
  orgsLoading: orgsFetching && !orgsError && !orgs,
4905
5011
  orgPending: false,
5012
+ activeRawRole: orgs?.find((o) => o.id === orgId)?.rawRole ?? null,
4906
5013
  navDisplay,
4907
5014
  setNavDisplay
4908
5015
  };