@the-portland-company/shell 0.52.0 → 0.53.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,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,182 @@ 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.jsx("img", { src: "/politogy-lockup-white.svg", alt: "Politogy VRM", style: { width: 220, height: "auto" } }),
3382
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { width: 320, maxWidth: "80vw" }, children: [
3383
+ /* @__PURE__ */ jsxRuntime.jsx(
3384
+ "div",
3385
+ {
3386
+ role: "progressbar",
3387
+ "aria-valuemin": 0,
3388
+ "aria-valuemax": 100,
3389
+ "aria-valuenow": Math.round(clampedPct),
3390
+ "aria-label": "Loading your workspace",
3391
+ style: {
3392
+ height: 3,
3393
+ width: "100%",
3394
+ borderRadius: 2,
3395
+ background: "rgba(255,255,255,0.12)",
3396
+ overflow: "hidden"
3397
+ },
3398
+ children: /* @__PURE__ */ jsxRuntime.jsx(
3399
+ "div",
3400
+ {
3401
+ style: {
3402
+ height: "100%",
3403
+ width: `${clampedPct}%`,
3404
+ background: "linear-gradient(90deg, var(--color-race-blue, #1d4ed8), var(--color-race-red, #dc2626))",
3405
+ transition: reducedMotion ? void 0 : `width ${FILL_EASE_MS}ms ease-out`
3406
+ }
3407
+ }
3408
+ )
3409
+ }
3410
+ ),
3411
+ /* @__PURE__ */ jsxRuntime.jsx(
3412
+ "div",
3413
+ {
3414
+ role: "status",
3415
+ "aria-live": "polite",
3416
+ style: {
3417
+ marginTop: "0.75rem",
3418
+ fontFamily: "var(--font-mono, ui-monospace, monospace)",
3419
+ fontSize: "0.8125rem",
3420
+ color: isError ? "var(--color-race-red, #dc2626)" : "rgba(255,255,255,0.72)",
3421
+ textAlign: "center"
3422
+ },
3423
+ children: announced
3424
+ }
3425
+ ),
3426
+ isError && (bootProgress?.onRetry || bootProgress?.onSignOut) && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: "1rem", display: "flex", gap: "0.75rem", justifyContent: "center" }, children: [
3427
+ bootProgress?.onRetry && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: bootProgress.onRetry, children: "Retry" }),
3428
+ bootProgress?.onSignOut && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: bootProgress.onSignOut, children: "Sign out" })
3429
+ ] })
3430
+ ] })
3431
+ ]
3432
+ }
3433
+ );
3434
+ }
3259
3435
  var CORE_APP = "vrm";
3260
3436
  var DEFAULT_SIGN_OUT_URL = "/auth/signout";
3261
3437
  var DEFAULT_BRAND_LOGO = "/brand/logos/relationship-mode-logo.png";
@@ -3306,6 +3482,8 @@ function PolitogyAppFrame({
3306
3482
  signOutUrl = DEFAULT_SIGN_OUT_URL,
3307
3483
  buildMeta,
3308
3484
  data,
3485
+ bootProgress,
3486
+ appReady,
3309
3487
  children
3310
3488
  }) {
3311
3489
  const meta = POLITOGY_APPS[app];
@@ -3334,6 +3512,8 @@ function PolitogyAppFrame({
3334
3512
  signOutUrl,
3335
3513
  buildMeta,
3336
3514
  data,
3515
+ bootProgress,
3516
+ appReady,
3337
3517
  children
3338
3518
  }
3339
3519
  )
@@ -3350,10 +3530,33 @@ function FrameBody({
3350
3530
  signOutUrl,
3351
3531
  buildMeta,
3352
3532
  data,
3533
+ bootProgress,
3534
+ appReady,
3353
3535
  children
3354
3536
  }) {
3355
3537
  const meta = POLITOGY_APPS[app];
3356
3538
  const chrome = useChromeState();
3539
+ const bootAppMode = meta.mode;
3540
+ const bootOrgId = chrome?.orgId ?? null;
3541
+ const [bootVisible, setBootVisible] = react.useState(true);
3542
+ const [bootGeneration, setBootGeneration] = react.useState(0);
3543
+ const seenOrgRef = react.useRef(bootOrgId);
3544
+ const seenModeRef = react.useRef(bootAppMode);
3545
+ react.useEffect(() => {
3546
+ if (seenOrgRef.current !== null && seenOrgRef.current !== bootOrgId) {
3547
+ setBootVisible(true);
3548
+ setBootGeneration((g) => g + 1);
3549
+ }
3550
+ seenOrgRef.current = bootOrgId;
3551
+ }, [bootOrgId]);
3552
+ react.useEffect(() => {
3553
+ if (seenModeRef.current !== void 0 && seenModeRef.current !== bootAppMode) {
3554
+ setBootVisible(true);
3555
+ setBootGeneration((g) => g + 1);
3556
+ }
3557
+ seenModeRef.current = bootAppMode;
3558
+ }, [bootAppMode]);
3559
+ const chromeResolved = chrome == null || !chrome.orgsLoading;
3357
3560
  const signOutRef = react.useRef(onSignOut);
3358
3561
  signOutRef.current = onSignOut;
3359
3562
  const signingOutRef = react.useRef(false);
@@ -3428,70 +3631,82 @@ function FrameBody({
3428
3631
  [navItems, pathname, search, meta.origin]
3429
3632
  );
3430
3633
  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
3634
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3635
+ /* @__PURE__ */ jsxRuntime.jsx(
3636
+ LoadingScreen,
3637
+ {
3638
+ visible: bootVisible,
3639
+ bootProgress,
3640
+ syntheticReady: appReady ?? chromeResolved,
3641
+ onExitComplete: () => setBootVisible(false)
3465
3642
  },
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
3643
+ bootGeneration
3644
+ ),
3645
+ /* @__PURE__ */ jsxRuntime.jsx(
3646
+ NavAppShell,
3647
+ {
3648
+ sidebar: {
3649
+ pathname,
3650
+ brand: {
3651
+ logoSrc: core?.brandLogoSrc ?? modeBrandLogo(appMode),
3652
+ logoAlt: "Politogy",
3653
+ badgeLabel: "P",
3654
+ title: "Politogy"
3655
+ },
3656
+ nav: {
3657
+ // A flat rail's sub-menu IS the nav, so the 'tabs only' preference
3658
+ // (which strips children) would leave one childless parent row —
3659
+ // flat apps ignore the stripping.
3660
+ items: navDisplay === "tabs" && meta.navShape !== "flat" ? navItems.map(({ children: _sub, ...item }) => item) : navItems,
3661
+ flatten: meta.navShape === "flat"
3662
+ },
3663
+ // The app's own workspace is NOT the organization — it is a per-app
3664
+ // bootstrap account with a different id in every app. Showing it while
3665
+ // the chrome API resolves flashed the wrong org on every load ("not
3666
+ // connected to the account"). Shimmer while in flight; only fall back
3667
+ // to the app label once the chrome lookup has actually failed.
3668
+ org: activeCoreOrg ? {
3669
+ name: activeCoreOrg.name,
3670
+ id: activeCoreOrg.id,
3671
+ logoUrl: activeCoreOrg.logoUrl,
3672
+ brandColor: activeCoreOrg.brandColor,
3673
+ organizations: coreOrgs,
3674
+ onSwitch: chrome?.setOrgId
3675
+ } : chrome?.orgsLoading ? { name: null, id: null, loading: true } : { name: session.accountName, id: session.accountId },
3676
+ mode,
3677
+ footer,
3678
+ linkComponent
3481
3679
  },
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
- );
3680
+ betaBanner: meta.beta === false ? void 0 : {},
3681
+ modeId: appMode,
3682
+ contentWrapper: core?.contentWrapper ?? granted("contentWrapper"),
3683
+ topBar: {
3684
+ // Core's pages publish their own identity; every other app is one
3685
+ // product surface and takes its registry title.
3686
+ title: core?.page?.title ?? (grants?.includes("title") ? data?.frame?.title : void 0) ?? meta.title,
3687
+ breadcrumbs,
3688
+ user: {
3689
+ name: identity.userName,
3690
+ email: identity.email,
3691
+ // The chrome API's avatar wins; the app may supply a fallback chain
3692
+ // (e.g. a stored memoji) for users with no uploaded photo.
3693
+ avatarUrl: identity.avatarUrl ?? data?.fallbackAvatarUrl ?? null,
3694
+ role: session.role
3695
+ },
3696
+ userMenu,
3697
+ onSignOut: handleSignOut,
3698
+ // The action cluster is IDENTICAL everywhere — core included. Page
3699
+ // actions sit BEFORE it, never inside it, so the Alerts bell keeps its
3700
+ // required position: farthest right, immediately before My Account.
3701
+ actions: /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3702
+ core?.page?.actions,
3703
+ /* @__PURE__ */ jsxRuntime.jsx(ChromeTopBarActions, {})
3704
+ ] })
3705
+ },
3706
+ children
3707
+ }
3708
+ )
3709
+ ] });
3495
3710
  }
3496
3711
  var standardNavIcons = {
3497
3712
  home: fi.FiHome,
@@ -3969,6 +4184,7 @@ function useChromeMode(arg = {}) {
3969
4184
  }
3970
4185
 
3971
4186
  exports.ChromeTopBarActions = ChromeTopBarActions;
4187
+ exports.LoadingScreen = LoadingScreen;
3972
4188
  exports.POLITOGY_APPS = POLITOGY_APPS;
3973
4189
  exports.PolitogyAppChrome = PolitogyAppChrome;
3974
4190
  exports.PolitogyAppFrame = PolitogyAppFrame;