@the-portland-company/shell 0.52.0 → 0.53.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,22 @@
1
+ ## 0.53.0 — 2026-09-29
2
+
3
+ ### Added — Loading Screen boot surface (Specs/Loading-Screen)
4
+
5
+ `PolitogyAppFrame` now renders the platform's boot screen: a full-screen
6
+ `ink-900` surface with the Politogy VRM lockup, a determinate/monotonic
7
+ gradient progress bar (Race Blue → Race Red), and a throttled monospace
8
+ status line, shown on cold start / hard reload / organization switch / mode
9
+ switch — the four events Global-Rules' Boot Screen Exception names — and
10
+ never for an in-page fetch or navigation. New export
11
+ `LoadingScreen`/`BootProgress` from `@the-portland-company/shell/app-chrome`
12
+ lets an app report real pipeline stages; a new `PolitogyAppFrame` prop
13
+ `appReady` lets an app dismiss the screen explicitly. Neither is required:
14
+ an app that passes nothing gets a truthful two-stage report driven by the
15
+ same org/nav resolution signal the frame already computes, so all 14
16
+ consumer apps keep working unchanged. `prefers-reduced-motion` skips the
17
+ exit transition; the bar is `role="progressbar"` with `aria-live` announced
18
+ copy throttled to one per 1.5s (final "Ready." always announced).
19
+
1
20
  ## 0.52.0 — 2026-09-12
2
21
 
3
22
  ### Added — mobile navigation (GLOBAL)
@@ -3256,6 +3256,216 @@ function watchLogout(onLogout, options = {}) {
3256
3256
  }
3257
3257
  return cleanup;
3258
3258
  }
3259
+ var SYNTHETIC_STAGES = [
3260
+ { pct: 20, label: "Connecting to your organization\u2026" },
3261
+ { pct: 100, label: "Ready." }
3262
+ ];
3263
+ var ANNOUNCE_THROTTLE_MS = 1500;
3264
+ var MIN_DWELL_MS = 450;
3265
+ var FILL_EASE_MS = 380;
3266
+ var HOLD_MS = 240;
3267
+ var FADE_MS = 300;
3268
+ function usePrefersReducedMotion() {
3269
+ const [reduced, setReduced] = react.useState(false);
3270
+ react.useEffect(() => {
3271
+ if (typeof window === "undefined" || !window.matchMedia) return;
3272
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
3273
+ setReduced(mq.matches);
3274
+ const onChange = () => setReduced(mq.matches);
3275
+ mq.addEventListener("change", onChange);
3276
+ return () => mq.removeEventListener("change", onChange);
3277
+ }, []);
3278
+ return reduced;
3279
+ }
3280
+ function useThrottledAnnouncement(label, isFinal) {
3281
+ const [announced, setAnnounced] = react.useState(label);
3282
+ const lastAtRef = react.useRef(0);
3283
+ react.useEffect(() => {
3284
+ const now = Date.now();
3285
+ if (isFinal || now - lastAtRef.current >= ANNOUNCE_THROTTLE_MS) {
3286
+ lastAtRef.current = now;
3287
+ setAnnounced(label);
3288
+ return;
3289
+ }
3290
+ const wait = ANNOUNCE_THROTTLE_MS - (now - lastAtRef.current);
3291
+ const t = setTimeout(() => {
3292
+ lastAtRef.current = Date.now();
3293
+ setAnnounced(label);
3294
+ }, wait);
3295
+ return () => clearTimeout(t);
3296
+ }, [label, isFinal]);
3297
+ return announced;
3298
+ }
3299
+ function useDwelledLabel(label) {
3300
+ const [displayed, setDisplayed] = react.useState(label);
3301
+ const shownAtRef = react.useRef(0);
3302
+ const pendingRef = react.useRef(null);
3303
+ react.useEffect(() => {
3304
+ if (label === displayed) return;
3305
+ const elapsed = Date.now() - shownAtRef.current;
3306
+ if (pendingRef.current) clearTimeout(pendingRef.current);
3307
+ if (elapsed >= MIN_DWELL_MS) {
3308
+ shownAtRef.current = Date.now();
3309
+ setDisplayed(label);
3310
+ } else {
3311
+ pendingRef.current = setTimeout(() => {
3312
+ shownAtRef.current = Date.now();
3313
+ setDisplayed(label);
3314
+ }, MIN_DWELL_MS - elapsed);
3315
+ }
3316
+ return () => {
3317
+ if (pendingRef.current) clearTimeout(pendingRef.current);
3318
+ };
3319
+ }, [label]);
3320
+ return displayed;
3321
+ }
3322
+ function LoadingScreen({
3323
+ bootProgress,
3324
+ syntheticReady,
3325
+ visible,
3326
+ onExitComplete
3327
+ }) {
3328
+ const reducedMotion = usePrefersReducedMotion();
3329
+ const synthetic = react.useMemo(() => {
3330
+ const stage = syntheticReady ? SYNTHETIC_STAGES[1] : SYNTHETIC_STAGES[0];
3331
+ return {
3332
+ pct: stage.pct,
3333
+ label: stage.label,
3334
+ phase: syntheticReady ? "done" : "booting"
3335
+ };
3336
+ }, [syntheticReady]);
3337
+ const pct = bootProgress?.pct ?? synthetic.pct;
3338
+ const rawLabel = bootProgress?.label ?? synthetic.label;
3339
+ const phase = bootProgress?.phase ?? synthetic.phase;
3340
+ const isFinal = phase === "done" && pct >= 100;
3341
+ const highWaterRef = react.useRef(0);
3342
+ const clampedPct = Math.max(pct, highWaterRef.current);
3343
+ react.useEffect(() => {
3344
+ highWaterRef.current = clampedPct;
3345
+ }, [clampedPct]);
3346
+ const displayedLabel = useDwelledLabel(rawLabel);
3347
+ const announced = useThrottledAnnouncement(displayedLabel, isFinal);
3348
+ const [fading, setFading] = react.useState(false);
3349
+ react.useEffect(() => {
3350
+ if (!isFinal) return;
3351
+ const holdMs = reducedMotion ? 0 : HOLD_MS;
3352
+ const fadeMs = reducedMotion ? 0 : FADE_MS;
3353
+ const holdTimer = setTimeout(() => {
3354
+ setFading(true);
3355
+ const fadeTimer = setTimeout(onExitComplete, fadeMs);
3356
+ return () => clearTimeout(fadeTimer);
3357
+ }, holdMs);
3358
+ return () => clearTimeout(holdTimer);
3359
+ }, [isFinal, reducedMotion]);
3360
+ if (!visible) return null;
3361
+ const isError = phase === "error";
3362
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3363
+ "div",
3364
+ {
3365
+ "data-testid": "politogy-loading-screen",
3366
+ style: {
3367
+ position: "fixed",
3368
+ inset: 0,
3369
+ zIndex: 2147483e3,
3370
+ display: "flex",
3371
+ flexDirection: "column",
3372
+ alignItems: "center",
3373
+ justifyContent: "center",
3374
+ gap: "2rem",
3375
+ background: "var(--color-ink-900, #0a0a0c)",
3376
+ color: "#fff",
3377
+ opacity: fading ? 0 : 1,
3378
+ transition: reducedMotion ? void 0 : `opacity ${FADE_MS}ms ease-out`
3379
+ },
3380
+ children: [
3381
+ /* @__PURE__ */ jsxRuntime.jsxs(
3382
+ "div",
3383
+ {
3384
+ "aria-label": "Politogy",
3385
+ style: {
3386
+ display: "flex",
3387
+ alignItems: "center",
3388
+ gap: "0.6rem",
3389
+ fontFamily: "var(--font-sans, ui-sans-serif, system-ui, sans-serif)"
3390
+ },
3391
+ children: [
3392
+ /* @__PURE__ */ jsxRuntime.jsx(
3393
+ "span",
3394
+ {
3395
+ "aria-hidden": "true",
3396
+ style: {
3397
+ display: "inline-flex",
3398
+ alignItems: "center",
3399
+ justifyContent: "center",
3400
+ width: 34,
3401
+ height: 26,
3402
+ borderRadius: 6,
3403
+ background: "#fff",
3404
+ color: "var(--color-ink-900, #0a0a0c)",
3405
+ fontSize: "0.8125rem",
3406
+ fontWeight: 800,
3407
+ letterSpacing: "-0.02em"
3408
+ },
3409
+ children: "vrm"
3410
+ }
3411
+ ),
3412
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "1.375rem", fontWeight: 700, letterSpacing: "-0.02em" }, children: "Politogy" })
3413
+ ]
3414
+ }
3415
+ ),
3416
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { width: 320, maxWidth: "80vw" }, children: [
3417
+ /* @__PURE__ */ jsxRuntime.jsx(
3418
+ "div",
3419
+ {
3420
+ role: "progressbar",
3421
+ "aria-valuemin": 0,
3422
+ "aria-valuemax": 100,
3423
+ "aria-valuenow": Math.round(clampedPct),
3424
+ "aria-label": "Loading your workspace",
3425
+ style: {
3426
+ height: 3,
3427
+ width: "100%",
3428
+ borderRadius: 2,
3429
+ background: "rgba(255,255,255,0.12)",
3430
+ overflow: "hidden"
3431
+ },
3432
+ children: /* @__PURE__ */ jsxRuntime.jsx(
3433
+ "div",
3434
+ {
3435
+ style: {
3436
+ height: "100%",
3437
+ width: `${clampedPct}%`,
3438
+ background: "linear-gradient(90deg, var(--color-race-blue, #1d4ed8), var(--color-race-red, #dc2626))",
3439
+ transition: reducedMotion ? void 0 : `width ${FILL_EASE_MS}ms ease-out`
3440
+ }
3441
+ }
3442
+ )
3443
+ }
3444
+ ),
3445
+ /* @__PURE__ */ jsxRuntime.jsx(
3446
+ "div",
3447
+ {
3448
+ role: "status",
3449
+ "aria-live": "polite",
3450
+ style: {
3451
+ marginTop: "0.75rem",
3452
+ fontFamily: "var(--font-mono, ui-monospace, monospace)",
3453
+ fontSize: "0.8125rem",
3454
+ color: isError ? "var(--color-race-red, #dc2626)" : "rgba(255,255,255,0.72)",
3455
+ textAlign: "center"
3456
+ },
3457
+ children: announced
3458
+ }
3459
+ ),
3460
+ isError && (bootProgress?.onRetry || bootProgress?.onSignOut) && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: "1rem", display: "flex", gap: "0.75rem", justifyContent: "center" }, children: [
3461
+ bootProgress?.onRetry && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: bootProgress.onRetry, children: "Retry" }),
3462
+ bootProgress?.onSignOut && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: bootProgress.onSignOut, children: "Sign out" })
3463
+ ] })
3464
+ ] })
3465
+ ]
3466
+ }
3467
+ );
3468
+ }
3259
3469
  var CORE_APP = "vrm";
3260
3470
  var DEFAULT_SIGN_OUT_URL = "/auth/signout";
3261
3471
  var DEFAULT_BRAND_LOGO = "/brand/logos/relationship-mode-logo.png";
@@ -3306,6 +3516,8 @@ function PolitogyAppFrame({
3306
3516
  signOutUrl = DEFAULT_SIGN_OUT_URL,
3307
3517
  buildMeta,
3308
3518
  data,
3519
+ bootProgress,
3520
+ appReady,
3309
3521
  children
3310
3522
  }) {
3311
3523
  const meta = POLITOGY_APPS[app];
@@ -3334,6 +3546,8 @@ function PolitogyAppFrame({
3334
3546
  signOutUrl,
3335
3547
  buildMeta,
3336
3548
  data,
3549
+ bootProgress,
3550
+ appReady,
3337
3551
  children
3338
3552
  }
3339
3553
  )
@@ -3350,10 +3564,33 @@ function FrameBody({
3350
3564
  signOutUrl,
3351
3565
  buildMeta,
3352
3566
  data,
3567
+ bootProgress,
3568
+ appReady,
3353
3569
  children
3354
3570
  }) {
3355
3571
  const meta = POLITOGY_APPS[app];
3356
3572
  const chrome = useChromeState();
3573
+ const bootAppMode = meta.mode;
3574
+ const bootOrgId = chrome?.orgId ?? null;
3575
+ const [bootVisible, setBootVisible] = react.useState(true);
3576
+ const [bootGeneration, setBootGeneration] = react.useState(0);
3577
+ const seenOrgRef = react.useRef(bootOrgId);
3578
+ const seenModeRef = react.useRef(bootAppMode);
3579
+ react.useEffect(() => {
3580
+ if (seenOrgRef.current !== null && seenOrgRef.current !== bootOrgId) {
3581
+ setBootVisible(true);
3582
+ setBootGeneration((g) => g + 1);
3583
+ }
3584
+ seenOrgRef.current = bootOrgId;
3585
+ }, [bootOrgId]);
3586
+ react.useEffect(() => {
3587
+ if (seenModeRef.current !== void 0 && seenModeRef.current !== bootAppMode) {
3588
+ setBootVisible(true);
3589
+ setBootGeneration((g) => g + 1);
3590
+ }
3591
+ seenModeRef.current = bootAppMode;
3592
+ }, [bootAppMode]);
3593
+ const chromeResolved = chrome == null || !chrome.orgsLoading;
3357
3594
  const signOutRef = react.useRef(onSignOut);
3358
3595
  signOutRef.current = onSignOut;
3359
3596
  const signingOutRef = react.useRef(false);
@@ -3428,70 +3665,82 @@ function FrameBody({
3428
3665
  [navItems, pathname, search, meta.origin]
3429
3666
  );
3430
3667
  const breadcrumbs = core ? core.page?.breadcrumbs : grants?.includes("breadcrumbs") ? granted("breadcrumbs") : derivedBreadcrumbs;
3431
- return /* @__PURE__ */ jsxRuntime.jsx(
3432
- NavAppShell,
3433
- {
3434
- sidebar: {
3435
- pathname,
3436
- brand: {
3437
- logoSrc: core?.brandLogoSrc ?? modeBrandLogo(appMode),
3438
- logoAlt: "Politogy",
3439
- badgeLabel: "P",
3440
- title: "Politogy"
3441
- },
3442
- nav: {
3443
- // A flat rail's sub-menu IS the nav, so the 'tabs only' preference
3444
- // (which strips children) would leave one childless parent row —
3445
- // flat apps ignore the stripping.
3446
- items: navDisplay === "tabs" && meta.navShape !== "flat" ? navItems.map(({ children: _sub, ...item }) => item) : navItems,
3447
- flatten: meta.navShape === "flat"
3448
- },
3449
- // The app's own workspace is NOT the organization — it is a per-app
3450
- // bootstrap account with a different id in every app. Showing it while
3451
- // the chrome API resolves flashed the wrong org on every load ("not
3452
- // connected to the account"). Shimmer while in flight; only fall back
3453
- // to the app label once the chrome lookup has actually failed.
3454
- org: activeCoreOrg ? {
3455
- name: activeCoreOrg.name,
3456
- id: activeCoreOrg.id,
3457
- logoUrl: activeCoreOrg.logoUrl,
3458
- brandColor: activeCoreOrg.brandColor,
3459
- organizations: coreOrgs,
3460
- onSwitch: chrome?.setOrgId
3461
- } : chrome?.orgsLoading ? { name: null, id: null, loading: true } : { name: session.accountName, id: session.accountId },
3462
- mode,
3463
- footer,
3464
- linkComponent
3668
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3669
+ /* @__PURE__ */ jsxRuntime.jsx(
3670
+ LoadingScreen,
3671
+ {
3672
+ visible: bootVisible,
3673
+ bootProgress,
3674
+ syntheticReady: appReady ?? chromeResolved,
3675
+ onExitComplete: () => setBootVisible(false)
3465
3676
  },
3466
- betaBanner: meta.beta === false ? void 0 : {},
3467
- modeId: appMode,
3468
- contentWrapper: core?.contentWrapper ?? granted("contentWrapper"),
3469
- topBar: {
3470
- // Core's pages publish their own identity; every other app is one
3471
- // product surface and takes its registry title.
3472
- title: core?.page?.title ?? (grants?.includes("title") ? data?.frame?.title : void 0) ?? meta.title,
3473
- breadcrumbs,
3474
- user: {
3475
- name: identity.userName,
3476
- email: identity.email,
3477
- // The chrome API's avatar wins; the app may supply a fallback chain
3478
- // (e.g. a stored memoji) for users with no uploaded photo.
3479
- avatarUrl: identity.avatarUrl ?? data?.fallbackAvatarUrl ?? null,
3480
- role: session.role
3677
+ bootGeneration
3678
+ ),
3679
+ /* @__PURE__ */ jsxRuntime.jsx(
3680
+ NavAppShell,
3681
+ {
3682
+ sidebar: {
3683
+ pathname,
3684
+ brand: {
3685
+ logoSrc: core?.brandLogoSrc ?? modeBrandLogo(appMode),
3686
+ logoAlt: "Politogy",
3687
+ badgeLabel: "P",
3688
+ title: "Politogy"
3689
+ },
3690
+ nav: {
3691
+ // A flat rail's sub-menu IS the nav, so the 'tabs only' preference
3692
+ // (which strips children) would leave one childless parent row —
3693
+ // flat apps ignore the stripping.
3694
+ items: navDisplay === "tabs" && meta.navShape !== "flat" ? navItems.map(({ children: _sub, ...item }) => item) : navItems,
3695
+ flatten: meta.navShape === "flat"
3696
+ },
3697
+ // The app's own workspace is NOT the organization — it is a per-app
3698
+ // bootstrap account with a different id in every app. Showing it while
3699
+ // the chrome API resolves flashed the wrong org on every load ("not
3700
+ // connected to the account"). Shimmer while in flight; only fall back
3701
+ // to the app label once the chrome lookup has actually failed.
3702
+ org: activeCoreOrg ? {
3703
+ name: activeCoreOrg.name,
3704
+ id: activeCoreOrg.id,
3705
+ logoUrl: activeCoreOrg.logoUrl,
3706
+ brandColor: activeCoreOrg.brandColor,
3707
+ organizations: coreOrgs,
3708
+ onSwitch: chrome?.setOrgId
3709
+ } : chrome?.orgsLoading ? { name: null, id: null, loading: true } : { name: session.accountName, id: session.accountId },
3710
+ mode,
3711
+ footer,
3712
+ linkComponent
3481
3713
  },
3482
- userMenu,
3483
- onSignOut: handleSignOut,
3484
- // The action cluster is IDENTICAL everywhere — core included. Page
3485
- // actions sit BEFORE it, never inside it, so the Alerts bell keeps its
3486
- // required position: farthest right, immediately before My Account.
3487
- actions: /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3488
- core?.page?.actions,
3489
- /* @__PURE__ */ jsxRuntime.jsx(ChromeTopBarActions, {})
3490
- ] })
3491
- },
3492
- children
3493
- }
3494
- );
3714
+ betaBanner: meta.beta === false ? void 0 : {},
3715
+ modeId: appMode,
3716
+ contentWrapper: core?.contentWrapper ?? granted("contentWrapper"),
3717
+ topBar: {
3718
+ // Core's pages publish their own identity; every other app is one
3719
+ // product surface and takes its registry title.
3720
+ title: core?.page?.title ?? (grants?.includes("title") ? data?.frame?.title : void 0) ?? meta.title,
3721
+ breadcrumbs,
3722
+ user: {
3723
+ name: identity.userName,
3724
+ email: identity.email,
3725
+ // The chrome API's avatar wins; the app may supply a fallback chain
3726
+ // (e.g. a stored memoji) for users with no uploaded photo.
3727
+ avatarUrl: identity.avatarUrl ?? data?.fallbackAvatarUrl ?? null,
3728
+ role: session.role
3729
+ },
3730
+ userMenu,
3731
+ onSignOut: handleSignOut,
3732
+ // The action cluster is IDENTICAL everywhere — core included. Page
3733
+ // actions sit BEFORE it, never inside it, so the Alerts bell keeps its
3734
+ // required position: farthest right, immediately before My Account.
3735
+ actions: /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3736
+ core?.page?.actions,
3737
+ /* @__PURE__ */ jsxRuntime.jsx(ChromeTopBarActions, {})
3738
+ ] })
3739
+ },
3740
+ children
3741
+ }
3742
+ )
3743
+ ] });
3495
3744
  }
3496
3745
  var standardNavIcons = {
3497
3746
  home: fi.FiHome,
@@ -3969,6 +4218,7 @@ function useChromeMode(arg = {}) {
3969
4218
  }
3970
4219
 
3971
4220
  exports.ChromeTopBarActions = ChromeTopBarActions;
4221
+ exports.LoadingScreen = LoadingScreen;
3972
4222
  exports.POLITOGY_APPS = POLITOGY_APPS;
3973
4223
  exports.PolitogyAppChrome = PolitogyAppChrome;
3974
4224
  exports.PolitogyAppFrame = PolitogyAppFrame;