@the-portland-company/shell 0.78.0 → 0.80.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/dist/native.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- export { BETA_BANNER_DISMISS_KEY, CAMPAIGN_FEATURE_AVAILABILITY, NavAppShell, NavBetaBanner, NavSidebar, NavTabStrip, NavTopBar, clearBetaBannerDismissal, envChipClasses, isNavItemActive, modeIdFromLabel, shortEnvLabel, shortUserName, standardPolitogyNav } from './chunk-FUEIKJLN.js';
2
+ export { BETA_BANNER_DISMISS_KEY, CAMPAIGN_FEATURE_AVAILABILITY, NavAppShell, NavBetaBanner, NavSidebar, NavTabStrip, NavTopBar, clearBetaBannerDismissal, envChipClasses, isNavItemActive, modeIdFromLabel, shortEnvLabel, shortUserName, standardPolitogyNav } from './chunk-KXVRYN5D.js';
3
3
  export { CORE_APP_ORIGIN, CORE_BILLING_PATH, FormColumn, FuelAction, NavChromeProvider, NavDisplayAction, NavThemeControl, NotificationsAction, SPECS_ORIGIN, SpecOverlayAction, SupportAction, TopBarCountBadge, TopBarPanelArrow, TopBarPopover, TopBarTooltip, TopBarWidgetSlot, resolveOrgLogoUrl, topBarIconButtonClass, useChromeMe, useChromeOrgs, useNavChromeDedupedFetch, useNavChromeFetcher, useThemePreference } from './chunk-TUK2IEL5.js';
4
4
  import './chunk-BHRVZPZ7.js';
5
5
  import './chunk-7YVJ7HQG.js';
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // src/active-org.ts
4
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ function isOrgId(v) {
6
+ return typeof v === "string" && UUID_RE.test(v.trim());
7
+ }
8
+ function orgIdTail(orgId) {
9
+ return isOrgId(orgId) ? orgId.trim().toLowerCase().slice(-7) : null;
10
+ }
11
+
12
+ // src/url-state.ts
13
+ var URL_STATE_MODES = ["relationship", "campaign", "petition"];
14
+ var URL_STATE_KEYS = ["mode", "page", "tab", "section", "org_id"];
15
+ function slugify(label) {
16
+ return label.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/['’]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17
+ }
18
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
+ function isUrlStateMode(v) {
20
+ return typeof v === "string" && URL_STATE_MODES.includes(v);
21
+ }
22
+ function searchOf(input) {
23
+ if (input instanceof URLSearchParams) return new URLSearchParams(input);
24
+ if (!input) return new URLSearchParams();
25
+ const s = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input;
26
+ return new URLSearchParams(s);
27
+ }
28
+ var slugOrNull = (v) => v && SLUG_RE.test(v) ? v : null;
29
+ function shortOrgId(v) {
30
+ if (!v) return null;
31
+ const tail = orgIdTail(v);
32
+ if (tail) return tail;
33
+ const t = String(v).trim().toLowerCase();
34
+ return /^[0-9a-f]{6,7}$/.test(t) ? t : null;
35
+ }
36
+ function readUrlState(input) {
37
+ const params = searchOf(input);
38
+ const mode = params.get("mode");
39
+ const orgRaw = params.get("org_id");
40
+ const feature = new URLSearchParams();
41
+ for (const [k, v] of params) if (!URL_STATE_KEYS.includes(k)) feature.append(k, v);
42
+ return {
43
+ mode: isUrlStateMode(mode) ? mode : null,
44
+ page: slugOrNull(params.get("page")),
45
+ tab: slugOrNull(params.get("tab")),
46
+ section: slugOrNull(params.get("section")),
47
+ org_id: shortOrgId(orgRaw),
48
+ feature
49
+ };
50
+ }
51
+ function buildSearch(state) {
52
+ const out = new URLSearchParams();
53
+ for (const key of URL_STATE_KEYS) {
54
+ const v = state[key];
55
+ if (v) out.set(key, v);
56
+ }
57
+ for (const [k, v] of state.feature) out.append(k, v);
58
+ const s = out.toString();
59
+ return s ? `?${s}` : "";
60
+ }
61
+ function writeUrlState(current, patch) {
62
+ const state = readUrlState(current);
63
+ if (patch.mode !== void 0) state.mode = patch.mode;
64
+ if (patch.page !== void 0) state.page = patch.page;
65
+ if (patch.tab !== void 0) state.tab = patch.tab;
66
+ if (patch.section !== void 0) state.section = patch.section;
67
+ if (patch.org_id !== void 0) state.org_id = shortOrgId(patch.org_id);
68
+ if (patch.feature) {
69
+ for (const [k, v] of Object.entries(patch.feature)) {
70
+ if (URL_STATE_KEYS.includes(k)) continue;
71
+ if (v === null) state.feature.delete(k);
72
+ else state.feature.set(k, v);
73
+ }
74
+ }
75
+ return buildSearch(state);
76
+ }
77
+ function normalizeUrlState(current, defaults) {
78
+ const before = searchOf(current).toString();
79
+ const state = readUrlState(current);
80
+ if (!state.page) state.page = defaults.page;
81
+ if (!state.mode && defaults.mode) state.mode = defaults.mode;
82
+ if (defaults.tabs && defaults.tabs.length > 0) {
83
+ if (!state.tab || !defaults.tabs.includes(state.tab)) state.tab = defaults.tabs[0];
84
+ } else {
85
+ state.tab = null;
86
+ }
87
+ const sectionsFor = defaults.sections?.[state.tab ?? ""] ?? (state.tab ? void 0 : defaults.sections?.[""]);
88
+ if (sectionsFor && sectionsFor.length > 0) {
89
+ if (!state.section || !sectionsFor.includes(state.section)) state.section = sectionsFor[0];
90
+ }
91
+ if (!state.org_id && defaults.org_id) state.org_id = shortOrgId(defaults.org_id);
92
+ const search = buildSearch(state);
93
+ return { state, search, changed: search.replace(/^\?/, "") !== before };
94
+ }
95
+ function historyMode(prev, next) {
96
+ return prev.mode !== next.mode || prev.org_id !== next.org_id || prev.page !== next.page || prev.tab !== next.tab ? "push" : "replace";
97
+ }
98
+
99
+ exports.URL_STATE_KEYS = URL_STATE_KEYS;
100
+ exports.URL_STATE_MODES = URL_STATE_MODES;
101
+ exports.buildSearch = buildSearch;
102
+ exports.historyMode = historyMode;
103
+ exports.isUrlStateMode = isUrlStateMode;
104
+ exports.normalizeUrlState = normalizeUrlState;
105
+ exports.readUrlState = readUrlState;
106
+ exports.slugify = slugify;
107
+ exports.writeUrlState = writeUrlState;
108
+ //# sourceMappingURL=url-state.cjs.map
109
+ //# sourceMappingURL=url-state.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/active-org.ts","../src/url-state.ts"],"names":[],"mappings":"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","file":"url-state.cjs","sourcesContent":["// Universal active-organization selection — the ONE cross-app contract.\n//\n// A user belongs to many orgs; ONE active-org choice must follow them across\n// every Politogy app + subdomain so all apps resolve the SAME org (and campaign\n// apps don't land on a rel-only org and lock themselves). Layers cooperate,\n// mirroring the theme-preference contract:\n// 1. URL `?org=<uuid>` — the authoritative per-request selector. The shared\n// chrome stamps it onto every route so it is bookmarkable + shareable, and\n// each app forwards it to the chrome API as `x-contacts-org`.\n// 2. `.politogyvrm.com` cookie `politogy_active_org` — cross-subdomain vehicle\n// (auth session cookies are host-only) that remembers the LAST USED org so\n// a bare URL in the next app opens on the same org.\n//\n// The stored/most-authoritative value is the FULL org UUID: contacts-api\n// (auth/vrmMembership.selectRequestedMembership) matches the selector by EXACT\n// id first, then falls back to a short suffix that can COLLIDE across a user's\n// orgs — so the full UUID is the only collision-free selector.\n//\n// Server-safe (no 'use client'): constants/resolve helpers are importable from\n// server layouts; the cookie read/write helpers no-op on the server.\n\nexport const ACTIVE_ORG_COOKIE = 'politogy_active_org'\nexport const ACTIVE_ORG_QUERY_PARAM = 'org'\n\n// Loose UUID shape check — we only ever store ids that came from the user's own\n// authoritative membership list, so this just rejects obvious garbage.\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport function isOrgId(v: unknown): v is string {\n return typeof v === 'string' && UUID_RE.test(v.trim())\n}\n\n/**\n * Read the shared active-org cookie (browser only; null on server or unset).\n * @deprecated v2 apps read the org from the token claims (`orgClaimsFromToken`).\n * The cookie stays written during rollout for apps not yet on the token path.\n */\nexport function readActiveOrgCookie(): string | null {\n if (typeof document === 'undefined') return null\n try {\n const m = document.cookie.match(\n new RegExp(`(?:^|;\\\\s*)${ACTIVE_ORG_COOKIE}=([^;]+)`),\n )\n const v = m ? decodeURIComponent(m[1]) : null\n return isOrgId(v) ? v : null\n } catch {\n return null\n }\n}\n\n// Write the cross-subdomain cookie with the SAME scoping rules as the theme\n// cookie: on a *.politogyvrm.com host scope to the parent domain so every app\n// subdomain reads it; on any other host (localhost, *.pages.dev preview) fall\n// back to a host-only cookie. 1-year, SameSite=Lax, Secure on https.\nexport function writeActiveOrgCookie(orgId: string): void {\n if (typeof document === 'undefined' || typeof window === 'undefined') return\n if (!isOrgId(orgId)) return\n const host = window.location.hostname\n const domainAttr = /(^|\\.)politogyvrm\\.com$/i.test(host) ? '; Domain=.politogyvrm.com' : ''\n const secure = window.location.protocol === 'https:' ? '; Secure' : ''\n document.cookie = `${ACTIVE_ORG_COOKIE}=${encodeURIComponent(\n orgId,\n )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`\n}\n\n/**\n * Read the `?org=` selector from a URL search string (full UUID only). Accepts a\n * `location.search`-style string or a full URL.\n */\nexport function readActiveOrgFromUrl(search: string | null | undefined): string | null {\n if (!search) return null\n try {\n const qs = search.includes('?') ? search.slice(search.indexOf('?') + 1) : search\n const v = new URLSearchParams(qs).get(ACTIVE_ORG_QUERY_PARAM)\n return isOrgId(v) ? v : null\n } catch {\n return null\n }\n}\n\n/**\n * Resolve the active org with the cross-app precedence:\n * 1. `?org=<uuid>` in the current URL (explicit per-request selector)\n * 2. the `.politogyvrm.com` cookie (last used, cross-subdomain)\n * 3. null → let the app fall back to the chrome-API default (earliest-joined)\n * Pass `search` on the server (from the request URL); it defaults to\n * `window.location.search` in the browser.\n * @deprecated v2 apps use the token `org_id` claim plus `readOrgSelectorFromUrl`\n * as a per-tab override only.\n */\nexport function resolveActiveOrg(search?: string | null): string | null {\n const fromUrl = readActiveOrgFromUrl(\n search ?? (typeof window !== 'undefined' ? window.location.search : null),\n )\n if (fromUrl) return fromUrl\n return readActiveOrgCookie()\n}\n\n/** The canonical `?org=` query fragment for a full org id (no leading `?`/`&`). */\nexport function activeOrgParam(orgId: string | null | undefined): string {\n return isOrgId(orgId) ? `${ACTIVE_ORG_QUERY_PARAM}=${orgId}` : ''\n}\n\n/**\n * Return `url` with `?org=<uuid>` set/replaced. Used by the shared chrome to\n * stamp the active org onto every route so it is always present in the address\n * bar. Leaves the URL unchanged when `orgId` is not a valid id.\n */\nexport function withActiveOrgParam(url: string, orgId: string | null | undefined): string {\n if (!isOrgId(orgId)) return url\n try {\n // Support both absolute and relative URLs.\n const base = typeof window !== 'undefined' ? window.location.origin : 'https://politogyvrm.com'\n const u = new URL(url, base)\n u.searchParams.set(ACTIVE_ORG_QUERY_PARAM, orgId as string)\n // Preserve relative form when the input was relative.\n return /^https?:\\/\\//i.test(url) ? u.toString() : `${u.pathname}${u.search}${u.hash}`\n } catch {\n return url\n }\n}\n\n// ---------------------------------------------------------------------------\n// v2: auth-owned active org (Global-Rules v38 \"Auth / Active Organization\",\n// UI-Platform v46 \"URL state\"). The IdP owns the shared active org on the SSO\n// session; tokens carry `org_id` (full uuid) + `org_role`. The URL carries the\n// 7-char `org_id` tail as a PER-TAB override only: it never changes the shared\n// active org. Full uuids and the legacy 6-char tail are accepted during rollout.\n// ---------------------------------------------------------------------------\n\nexport const ORG_ID_QUERY_PARAM = 'org_id'\nexport const ORG_ID_TAIL_LENGTH = 7\nconst LEGACY_ORG_TAIL_LENGTH = 6\nconst TAIL_RE = /^[0-9a-f]{6,7}$/i\n\nconst AUTH_BASE_URL = 'https://auth.politogyvrm.com'\nconst AUTH_BASE_URL_STAGING = 'https://auth-staging.politogyvrm.com'\n\n/** The URL form of an org id: the last 7 chars of the uuid, lower case. */\nexport function orgIdTail(orgId: string | null | undefined): string | null {\n return isOrgId(orgId) ? (orgId as string).trim().toLowerCase().slice(-ORG_ID_TAIL_LENGTH) : null\n}\n\n/** A full uuid, the 7-char tail, or the legacy 6-char tail. */\nexport function isOrgSelector(v: unknown): v is string {\n return typeof v === 'string' && (UUID_RE.test(v.trim()) || TAIL_RE.test(v.trim()))\n}\n\n/**\n * Resolve a selector against the user's own org list: exact uuid first, then a\n * unique 7-char tail, then a unique 6-char tail. Ambiguous or no match → null\n * (the IdP selector rules, so the shell never picks an org the IdP would not).\n */\nexport function matchOrgSelector<T extends { id: string }>(\n selector: string | null | undefined,\n orgs: readonly T[],\n): T | null {\n if (!isOrgSelector(selector)) return null\n const sel = selector.trim().toLowerCase()\n if (UUID_RE.test(sel)) return orgs.find((o) => o.id.toLowerCase() === sel) ?? null\n if (sel.length !== ORG_ID_TAIL_LENGTH && sel.length !== LEGACY_ORG_TAIL_LENGTH) return null\n const hits = orgs.filter((o) => o.id.toLowerCase().endsWith(sel))\n return hits.length === 1 ? hits[0] : null\n}\n\n/**\n * Read the per-tab org override from a URL: `org_id` (spec), else the legacy\n * `org`. Returns the raw selector (uuid or tail), or null. Never a source for\n * the shared active org.\n */\nexport function readOrgSelectorFromUrl(search: string | null | undefined): string | null {\n if (!search) return null\n try {\n const qs = search.includes('?') ? search.slice(search.indexOf('?') + 1) : search\n const params = new URLSearchParams(qs)\n for (const key of [ORG_ID_QUERY_PARAM, ACTIVE_ORG_QUERY_PARAM]) {\n const v = params.get(key)\n if (isOrgSelector(v)) return v.trim().toLowerCase()\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Return `url` with `org_id=<7-char tail>` set and any legacy `org=` removed.\n * Leaves the URL unchanged when `orgId` is not a full uuid.\n */\nexport function withOrgIdParam(url: string, orgId: string | null | undefined): string {\n const tail = orgIdTail(orgId)\n if (!tail) return url\n try {\n const base = typeof window !== 'undefined' ? window.location.origin : 'https://politogyvrm.com'\n const u = new URL(url, base)\n u.searchParams.delete(ACTIVE_ORG_QUERY_PARAM)\n u.searchParams.set(ORG_ID_QUERY_PARAM, tail)\n return /^https?:\\/\\//i.test(url) ? u.toString() : `${u.pathname}${u.search}${u.hash}`\n } catch {\n return url\n }\n}\n\nexport type OrgClaims = { orgId: string; orgRole: string | null }\n\n/** Pick `org_id` / `org_role` out of a token claim set. */\nexport function orgClaimsFrom(claims: unknown): OrgClaims | null {\n if (!claims || typeof claims !== 'object') return null\n const c = claims as { org_id?: unknown; org_role?: unknown }\n if (!isOrgId(c.org_id)) return null\n return {\n orgId: (c.org_id as string).trim().toLowerCase(),\n orgRole: typeof c.org_role === 'string' ? c.org_role : null,\n }\n}\n\n/**\n * Read `org_id` / `org_role` from a JWT payload WITHOUT verifying it. For\n * display and routing only: the app server verifies the token (JWKS) and every\n * authorization decision keys on the verified claim, never on this.\n */\nexport function orgClaimsFromToken(token: string | null | undefined): OrgClaims | null {\n if (typeof token !== 'string') return null\n const part = token.split('.')[1]\n if (!part) return null\n try {\n const b64 = part.replace(/-/g, '+').replace(/_/g, '/')\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)\n const json =\n typeof atob === 'function'\n ? decodeURIComponent(\n Array.from(atob(padded), (ch) => '%' + ch.charCodeAt(0).toString(16).padStart(2, '0')).join(''),\n )\n : ''\n return orgClaimsFrom(JSON.parse(json))\n } catch {\n return null\n }\n}\n\n/** The IdP base for the current environment (staging host → staging IdP). */\nexport function authBaseUrl(): string {\n if (typeof window !== 'undefined' && window.location.hostname.toLowerCase().includes('staging')) {\n return AUTH_BASE_URL_STAGING\n }\n return AUTH_BASE_URL\n}\n\nexport class ActiveOrgSwitchError extends Error {\n constructor(\n readonly status: number,\n message: string,\n ) {\n super(message)\n this.name = 'ActiveOrgSwitchError'\n }\n}\n\n/**\n * Switch the SHARED active org on the IdP: `POST {auth}/api/active-org`. The\n * IdP checks membership against VRM Core and updates the SSO session; the\n * caller then re-exchanges (server) for tokens carrying the new `org_id`.\n * Also mirrors the legacy cookie so apps not yet on v2 follow the switch.\n * Throws `ActiveOrgSwitchError` (403 = not a member, 401 = no SSO session).\n */\nexport async function switchActiveOrg(\n orgId: string,\n opts: { authBaseUrl?: string; fetch?: typeof fetch } = {},\n): Promise<OrgClaims> {\n if (!isOrgId(orgId)) throw new ActiveOrgSwitchError(400, 'org_id must be a full uuid')\n const doFetch = opts.fetch ?? fetch\n const res = await doFetch(`${opts.authBaseUrl ?? authBaseUrl()}/api/active-org`, {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ org_id: orgId }),\n })\n if (!res.ok) {\n let msg = `active-org switch failed (${res.status})`\n try {\n const body = (await res.json()) as { error?: string }\n if (body?.error) msg = body.error\n } catch {\n /* no body */\n }\n throw new ActiveOrgSwitchError(res.status, msg)\n }\n const claims = orgClaimsFrom(await res.json().catch(() => null))\n if (!claims) throw new ActiveOrgSwitchError(502, 'active-org switch returned no org_id')\n writeActiveOrgCookie(claims.orgId)\n return claims\n}\n\n/**\n * URL-sync rule for the address-bar org selector. Returns the new href, or\n * null when nothing should be written. A bare URL (no `org_id`, no legacy\n * `org`) stays bare: the shell must never inject `org_id` on its own, because\n * a self-injected selector then acts as a per-tab override and beats the token\n * org and the cross-app `politogy_active_org` trigger. Only a selector the user\n * brought (or a real switch put there) is canonicalized to the current org.\n */\nexport function syncOrgIdInUrl(href: string, orgId: string | null | undefined): string | null {\n const tail = orgIdTail(orgId)\n if (!tail) return null\n let u: URL\n try {\n u = new URL(href)\n } catch {\n return null\n }\n const hasLegacy = u.searchParams.has(ACTIVE_ORG_QUERY_PARAM)\n const current = u.searchParams.get(ORG_ID_QUERY_PARAM)\n if (!hasLegacy && current === null) return null\n if (!hasLegacy && current === tail) return null\n u.searchParams.delete(ACTIVE_ORG_QUERY_PARAM)\n u.searchParams.set(ORG_ID_QUERY_PARAM, tail)\n return u.toString()\n}\n","// URL state (Reference/UI-Platform → \"URL state\", decided 2026-10-06; Global\n// Rules → URL State). Every app that renders shell Navigation carries its view\n// state in five query parameters, in this order, then its own feature\n// parameters:\n//\n// ?mode=<mode>&page=<page>&tab=<tab>&section=<section>&org_id=<7-char org tail>[&feature…]\n//\n// The path carries no view state. This module is the ONE writer of those five\n// parameters: apps read and write them through it and never concatenate them\n// by hand. It is pure (no window, no router) so it runs on the server, in\n// middleware and in the client the same way; the app passes the result to its\n// router (`router.push` / `router.replace`) according to `historyMode`.\nimport { orgIdTail } from './active-org'\n\nexport const URL_STATE_MODES = ['relationship', 'campaign', 'petition'] as const\nexport type UrlStateMode = (typeof URL_STATE_MODES)[number]\n\n/** The five base parameters, in the order the spec fixes. */\nexport const URL_STATE_KEYS = ['mode', 'page', 'tab', 'section', 'org_id'] as const\nexport type UrlStateKey = (typeof URL_STATE_KEYS)[number]\n\nexport type UrlState = {\n mode: UrlStateMode | null\n page: string | null\n tab: string | null\n section: string | null\n /** The short org id (last 7 characters, lowercase) as it appears in the URL. */\n org_id: string | null\n /** Every other parameter (pagination, filters, record ids), in their own order. */\n feature: URLSearchParams\n}\n\n/** Lowercase kebab-case of a label, the spec's default slug (\"Tech Check\" → tech-check, \"This week's stats\" → this-weeks-stats). */\nexport function slugify(label: string): string {\n return label\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .replace(/['’]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nconst SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport function isUrlStateMode(v: unknown): v is UrlStateMode {\n return typeof v === 'string' && (URL_STATE_MODES as readonly string[]).includes(v)\n}\n\nfunction searchOf(input: string | URLSearchParams | null | undefined): URLSearchParams {\n if (input instanceof URLSearchParams) return new URLSearchParams(input)\n if (!input) return new URLSearchParams()\n const s = input.includes('?') ? input.slice(input.indexOf('?') + 1) : input\n return new URLSearchParams(s)\n}\n\nconst slugOrNull = (v: string | null): string | null => (v && SLUG_RE.test(v) ? v : null)\n\n/** A full org uuid becomes its 7-character tail; a tail (6–7 hex chars) is kept lowercase; anything else is dropped. */\nfunction shortOrgId(v: string | null | undefined): string | null {\n if (!v) return null\n const tail = orgIdTail(v)\n if (tail) return tail\n const t = String(v).trim().toLowerCase()\n return /^[0-9a-f]{6,7}$/.test(t) ? t : null\n}\n\n/** Reads the five parameters out of a search string, URL or URLSearchParams; the rest stays in `feature`. */\nexport function readUrlState(input: string | URLSearchParams | null | undefined): UrlState {\n const params = searchOf(input)\n const mode = params.get('mode')\n const orgRaw = params.get('org_id')\n const feature = new URLSearchParams()\n for (const [k, v] of params) if (!(URL_STATE_KEYS as readonly string[]).includes(k)) feature.append(k, v)\n return {\n mode: isUrlStateMode(mode) ? mode : null,\n page: slugOrNull(params.get('page')),\n tab: slugOrNull(params.get('tab')),\n section: slugOrNull(params.get('section')),\n org_id: shortOrgId(orgRaw),\n feature,\n }\n}\n\n/** Serializes a state back to a search string: the five parameters first, in order, then the feature parameters. */\nexport function buildSearch(state: UrlState): string {\n const out = new URLSearchParams()\n for (const key of URL_STATE_KEYS) {\n const v = state[key]\n if (v) out.set(key, v)\n }\n for (const [k, v] of state.feature) out.append(k, v)\n const s = out.toString()\n return s ? `?${s}` : ''\n}\n\nexport type UrlStatePatch = Partial<Omit<UrlState, 'feature' | 'org_id'>> & {\n /** A full org uuid or a short id; written as the 7-character tail. */\n org_id?: string | null\n /** Feature parameters to set (null removes one). */\n feature?: Record<string, string | null>\n}\n\n/** Applies a change to the state carried in `current` and returns the new search string. */\nexport function writeUrlState(current: string | URLSearchParams | null | undefined, patch: UrlStatePatch): string {\n const state = readUrlState(current)\n if (patch.mode !== undefined) state.mode = patch.mode\n if (patch.page !== undefined) state.page = patch.page\n if (patch.tab !== undefined) state.tab = patch.tab\n if (patch.section !== undefined) state.section = patch.section\n if (patch.org_id !== undefined) state.org_id = shortOrgId(patch.org_id)\n if (patch.feature) {\n for (const [k, v] of Object.entries(patch.feature)) {\n if ((URL_STATE_KEYS as readonly string[]).includes(k)) continue // never through the back door\n if (v === null) state.feature.delete(k)\n else state.feature.set(k, v)\n }\n }\n return buildSearch(state)\n}\n\nexport type UrlStateDefaults = {\n /** The page's registry slug; always present (UI-Platform: \"Decides the active left-bar row\"). */\n page: string\n mode?: UrlStateMode | null\n /** The page's tabs in order; the first is the default. Omit for a page with no tab strip. */\n tabs?: readonly string[]\n /** Each tab's (or the untabbed page's, under the key \"\") sections in order; the first is the default. */\n sections?: Readonly<Record<string, readonly string[]>>\n /** Full org uuid or short id of the current organization. */\n org_id?: string | null\n}\n\nexport type Normalized = { state: UrlState; search: string; changed: boolean }\n\n/**\n * Fills a missing parameter from the defaults and corrects an unknown `tab` or\n * `section` to the default (\"filled or corrected by the shell with a replace,\n * never an error page\"). `changed` says whether the URL needs rewriting.\n */\nexport function normalizeUrlState(current: string | URLSearchParams | null | undefined, defaults: UrlStateDefaults): Normalized {\n const before = searchOf(current).toString()\n const state = readUrlState(current)\n if (!state.page) state.page = defaults.page\n if (!state.mode && defaults.mode) state.mode = defaults.mode\n if (defaults.tabs && defaults.tabs.length > 0) {\n if (!state.tab || !defaults.tabs.includes(state.tab)) state.tab = defaults.tabs[0]!\n } else {\n state.tab = null\n }\n const sectionsFor = defaults.sections?.[state.tab ?? ''] ?? (state.tab ? undefined : defaults.sections?.[''])\n if (sectionsFor && sectionsFor.length > 0) {\n if (!state.section || !sectionsFor.includes(state.section)) state.section = sectionsFor[0]!\n }\n if (!state.org_id && defaults.org_id) state.org_id = shortOrgId(defaults.org_id)\n const search = buildSearch(state)\n return { state, search, changed: search.replace(/^\\?/, '') !== before }\n}\n\n/**\n * Which history action a change needs: `mode`, `org_id`, `page` and `tab`\n * changes add an entry (Back and Forward work); a `section`-only change, or a\n * normalization, replaces the current one.\n */\nexport function historyMode(prev: UrlState, next: UrlState): 'push' | 'replace' {\n return prev.mode !== next.mode || prev.org_id !== next.org_id || prev.page !== next.page || prev.tab !== next.tab ? 'push' : 'replace'\n}\n"]}
@@ -0,0 +1,60 @@
1
+ declare const URL_STATE_MODES: readonly ["relationship", "campaign", "petition"];
2
+ type UrlStateMode = (typeof URL_STATE_MODES)[number];
3
+ /** The five base parameters, in the order the spec fixes. */
4
+ declare const URL_STATE_KEYS: readonly ["mode", "page", "tab", "section", "org_id"];
5
+ type UrlStateKey = (typeof URL_STATE_KEYS)[number];
6
+ type UrlState = {
7
+ mode: UrlStateMode | null;
8
+ page: string | null;
9
+ tab: string | null;
10
+ section: string | null;
11
+ /** The short org id (last 7 characters, lowercase) as it appears in the URL. */
12
+ org_id: string | null;
13
+ /** Every other parameter (pagination, filters, record ids), in their own order. */
14
+ feature: URLSearchParams;
15
+ };
16
+ /** Lowercase kebab-case of a label, the spec's default slug ("Tech Check" → tech-check, "This week's stats" → this-weeks-stats). */
17
+ declare function slugify(label: string): string;
18
+ declare function isUrlStateMode(v: unknown): v is UrlStateMode;
19
+ /** Reads the five parameters out of a search string, URL or URLSearchParams; the rest stays in `feature`. */
20
+ declare function readUrlState(input: string | URLSearchParams | null | undefined): UrlState;
21
+ /** Serializes a state back to a search string: the five parameters first, in order, then the feature parameters. */
22
+ declare function buildSearch(state: UrlState): string;
23
+ type UrlStatePatch = Partial<Omit<UrlState, 'feature' | 'org_id'>> & {
24
+ /** A full org uuid or a short id; written as the 7-character tail. */
25
+ org_id?: string | null;
26
+ /** Feature parameters to set (null removes one). */
27
+ feature?: Record<string, string | null>;
28
+ };
29
+ /** Applies a change to the state carried in `current` and returns the new search string. */
30
+ declare function writeUrlState(current: string | URLSearchParams | null | undefined, patch: UrlStatePatch): string;
31
+ type UrlStateDefaults = {
32
+ /** The page's registry slug; always present (UI-Platform: "Decides the active left-bar row"). */
33
+ page: string;
34
+ mode?: UrlStateMode | null;
35
+ /** The page's tabs in order; the first is the default. Omit for a page with no tab strip. */
36
+ tabs?: readonly string[];
37
+ /** Each tab's (or the untabbed page's, under the key "") sections in order; the first is the default. */
38
+ sections?: Readonly<Record<string, readonly string[]>>;
39
+ /** Full org uuid or short id of the current organization. */
40
+ org_id?: string | null;
41
+ };
42
+ type Normalized = {
43
+ state: UrlState;
44
+ search: string;
45
+ changed: boolean;
46
+ };
47
+ /**
48
+ * Fills a missing parameter from the defaults and corrects an unknown `tab` or
49
+ * `section` to the default ("filled or corrected by the shell with a replace,
50
+ * never an error page"). `changed` says whether the URL needs rewriting.
51
+ */
52
+ declare function normalizeUrlState(current: string | URLSearchParams | null | undefined, defaults: UrlStateDefaults): Normalized;
53
+ /**
54
+ * Which history action a change needs: `mode`, `org_id`, `page` and `tab`
55
+ * changes add an entry (Back and Forward work); a `section`-only change, or a
56
+ * normalization, replaces the current one.
57
+ */
58
+ declare function historyMode(prev: UrlState, next: UrlState): 'push' | 'replace';
59
+
60
+ export { type Normalized, URL_STATE_KEYS, URL_STATE_MODES, type UrlState, type UrlStateDefaults, type UrlStateKey, type UrlStateMode, type UrlStatePatch, buildSearch, historyMode, isUrlStateMode, normalizeUrlState, readUrlState, slugify, writeUrlState };
@@ -0,0 +1,60 @@
1
+ declare const URL_STATE_MODES: readonly ["relationship", "campaign", "petition"];
2
+ type UrlStateMode = (typeof URL_STATE_MODES)[number];
3
+ /** The five base parameters, in the order the spec fixes. */
4
+ declare const URL_STATE_KEYS: readonly ["mode", "page", "tab", "section", "org_id"];
5
+ type UrlStateKey = (typeof URL_STATE_KEYS)[number];
6
+ type UrlState = {
7
+ mode: UrlStateMode | null;
8
+ page: string | null;
9
+ tab: string | null;
10
+ section: string | null;
11
+ /** The short org id (last 7 characters, lowercase) as it appears in the URL. */
12
+ org_id: string | null;
13
+ /** Every other parameter (pagination, filters, record ids), in their own order. */
14
+ feature: URLSearchParams;
15
+ };
16
+ /** Lowercase kebab-case of a label, the spec's default slug ("Tech Check" → tech-check, "This week's stats" → this-weeks-stats). */
17
+ declare function slugify(label: string): string;
18
+ declare function isUrlStateMode(v: unknown): v is UrlStateMode;
19
+ /** Reads the five parameters out of a search string, URL or URLSearchParams; the rest stays in `feature`. */
20
+ declare function readUrlState(input: string | URLSearchParams | null | undefined): UrlState;
21
+ /** Serializes a state back to a search string: the five parameters first, in order, then the feature parameters. */
22
+ declare function buildSearch(state: UrlState): string;
23
+ type UrlStatePatch = Partial<Omit<UrlState, 'feature' | 'org_id'>> & {
24
+ /** A full org uuid or a short id; written as the 7-character tail. */
25
+ org_id?: string | null;
26
+ /** Feature parameters to set (null removes one). */
27
+ feature?: Record<string, string | null>;
28
+ };
29
+ /** Applies a change to the state carried in `current` and returns the new search string. */
30
+ declare function writeUrlState(current: string | URLSearchParams | null | undefined, patch: UrlStatePatch): string;
31
+ type UrlStateDefaults = {
32
+ /** The page's registry slug; always present (UI-Platform: "Decides the active left-bar row"). */
33
+ page: string;
34
+ mode?: UrlStateMode | null;
35
+ /** The page's tabs in order; the first is the default. Omit for a page with no tab strip. */
36
+ tabs?: readonly string[];
37
+ /** Each tab's (or the untabbed page's, under the key "") sections in order; the first is the default. */
38
+ sections?: Readonly<Record<string, readonly string[]>>;
39
+ /** Full org uuid or short id of the current organization. */
40
+ org_id?: string | null;
41
+ };
42
+ type Normalized = {
43
+ state: UrlState;
44
+ search: string;
45
+ changed: boolean;
46
+ };
47
+ /**
48
+ * Fills a missing parameter from the defaults and corrects an unknown `tab` or
49
+ * `section` to the default ("filled or corrected by the shell with a replace,
50
+ * never an error page"). `changed` says whether the URL needs rewriting.
51
+ */
52
+ declare function normalizeUrlState(current: string | URLSearchParams | null | undefined, defaults: UrlStateDefaults): Normalized;
53
+ /**
54
+ * Which history action a change needs: `mode`, `org_id`, `page` and `tab`
55
+ * changes add an entry (Back and Forward work); a `section`-only change, or a
56
+ * normalization, replaces the current one.
57
+ */
58
+ declare function historyMode(prev: UrlState, next: UrlState): 'push' | 'replace';
59
+
60
+ export { type Normalized, URL_STATE_KEYS, URL_STATE_MODES, type UrlState, type UrlStateDefaults, type UrlStateKey, type UrlStateMode, type UrlStatePatch, buildSearch, historyMode, isUrlStateMode, normalizeUrlState, readUrlState, slugify, writeUrlState };
@@ -0,0 +1,92 @@
1
+ import { orgIdTail } from './chunk-FBUF7LUE.js';
2
+
3
+ // src/url-state.ts
4
+ var URL_STATE_MODES = ["relationship", "campaign", "petition"];
5
+ var URL_STATE_KEYS = ["mode", "page", "tab", "section", "org_id"];
6
+ function slugify(label) {
7
+ return label.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/['’]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8
+ }
9
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ function isUrlStateMode(v) {
11
+ return typeof v === "string" && URL_STATE_MODES.includes(v);
12
+ }
13
+ function searchOf(input) {
14
+ if (input instanceof URLSearchParams) return new URLSearchParams(input);
15
+ if (!input) return new URLSearchParams();
16
+ const s = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input;
17
+ return new URLSearchParams(s);
18
+ }
19
+ var slugOrNull = (v) => v && SLUG_RE.test(v) ? v : null;
20
+ function shortOrgId(v) {
21
+ if (!v) return null;
22
+ const tail = orgIdTail(v);
23
+ if (tail) return tail;
24
+ const t = String(v).trim().toLowerCase();
25
+ return /^[0-9a-f]{6,7}$/.test(t) ? t : null;
26
+ }
27
+ function readUrlState(input) {
28
+ const params = searchOf(input);
29
+ const mode = params.get("mode");
30
+ const orgRaw = params.get("org_id");
31
+ const feature = new URLSearchParams();
32
+ for (const [k, v] of params) if (!URL_STATE_KEYS.includes(k)) feature.append(k, v);
33
+ return {
34
+ mode: isUrlStateMode(mode) ? mode : null,
35
+ page: slugOrNull(params.get("page")),
36
+ tab: slugOrNull(params.get("tab")),
37
+ section: slugOrNull(params.get("section")),
38
+ org_id: shortOrgId(orgRaw),
39
+ feature
40
+ };
41
+ }
42
+ function buildSearch(state) {
43
+ const out = new URLSearchParams();
44
+ for (const key of URL_STATE_KEYS) {
45
+ const v = state[key];
46
+ if (v) out.set(key, v);
47
+ }
48
+ for (const [k, v] of state.feature) out.append(k, v);
49
+ const s = out.toString();
50
+ return s ? `?${s}` : "";
51
+ }
52
+ function writeUrlState(current, patch) {
53
+ const state = readUrlState(current);
54
+ if (patch.mode !== void 0) state.mode = patch.mode;
55
+ if (patch.page !== void 0) state.page = patch.page;
56
+ if (patch.tab !== void 0) state.tab = patch.tab;
57
+ if (patch.section !== void 0) state.section = patch.section;
58
+ if (patch.org_id !== void 0) state.org_id = shortOrgId(patch.org_id);
59
+ if (patch.feature) {
60
+ for (const [k, v] of Object.entries(patch.feature)) {
61
+ if (URL_STATE_KEYS.includes(k)) continue;
62
+ if (v === null) state.feature.delete(k);
63
+ else state.feature.set(k, v);
64
+ }
65
+ }
66
+ return buildSearch(state);
67
+ }
68
+ function normalizeUrlState(current, defaults) {
69
+ const before = searchOf(current).toString();
70
+ const state = readUrlState(current);
71
+ if (!state.page) state.page = defaults.page;
72
+ if (!state.mode && defaults.mode) state.mode = defaults.mode;
73
+ if (defaults.tabs && defaults.tabs.length > 0) {
74
+ if (!state.tab || !defaults.tabs.includes(state.tab)) state.tab = defaults.tabs[0];
75
+ } else {
76
+ state.tab = null;
77
+ }
78
+ const sectionsFor = defaults.sections?.[state.tab ?? ""] ?? (state.tab ? void 0 : defaults.sections?.[""]);
79
+ if (sectionsFor && sectionsFor.length > 0) {
80
+ if (!state.section || !sectionsFor.includes(state.section)) state.section = sectionsFor[0];
81
+ }
82
+ if (!state.org_id && defaults.org_id) state.org_id = shortOrgId(defaults.org_id);
83
+ const search = buildSearch(state);
84
+ return { state, search, changed: search.replace(/^\?/, "") !== before };
85
+ }
86
+ function historyMode(prev, next) {
87
+ return prev.mode !== next.mode || prev.org_id !== next.org_id || prev.page !== next.page || prev.tab !== next.tab ? "push" : "replace";
88
+ }
89
+
90
+ export { URL_STATE_KEYS, URL_STATE_MODES, buildSearch, historyMode, isUrlStateMode, normalizeUrlState, readUrlState, slugify, writeUrlState };
91
+ //# sourceMappingURL=url-state.js.map
92
+ //# sourceMappingURL=url-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/url-state.ts"],"names":[],"mappings":"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","file":"url-state.js","sourcesContent":["// URL state (Reference/UI-Platform → \"URL state\", decided 2026-10-06; Global\n// Rules → URL State). Every app that renders shell Navigation carries its view\n// state in five query parameters, in this order, then its own feature\n// parameters:\n//\n// ?mode=<mode>&page=<page>&tab=<tab>&section=<section>&org_id=<7-char org tail>[&feature…]\n//\n// The path carries no view state. This module is the ONE writer of those five\n// parameters: apps read and write them through it and never concatenate them\n// by hand. It is pure (no window, no router) so it runs on the server, in\n// middleware and in the client the same way; the app passes the result to its\n// router (`router.push` / `router.replace`) according to `historyMode`.\nimport { orgIdTail } from './active-org'\n\nexport const URL_STATE_MODES = ['relationship', 'campaign', 'petition'] as const\nexport type UrlStateMode = (typeof URL_STATE_MODES)[number]\n\n/** The five base parameters, in the order the spec fixes. */\nexport const URL_STATE_KEYS = ['mode', 'page', 'tab', 'section', 'org_id'] as const\nexport type UrlStateKey = (typeof URL_STATE_KEYS)[number]\n\nexport type UrlState = {\n mode: UrlStateMode | null\n page: string | null\n tab: string | null\n section: string | null\n /** The short org id (last 7 characters, lowercase) as it appears in the URL. */\n org_id: string | null\n /** Every other parameter (pagination, filters, record ids), in their own order. */\n feature: URLSearchParams\n}\n\n/** Lowercase kebab-case of a label, the spec's default slug (\"Tech Check\" → tech-check, \"This week's stats\" → this-weeks-stats). */\nexport function slugify(label: string): string {\n return label\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .replace(/['’]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nconst SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport function isUrlStateMode(v: unknown): v is UrlStateMode {\n return typeof v === 'string' && (URL_STATE_MODES as readonly string[]).includes(v)\n}\n\nfunction searchOf(input: string | URLSearchParams | null | undefined): URLSearchParams {\n if (input instanceof URLSearchParams) return new URLSearchParams(input)\n if (!input) return new URLSearchParams()\n const s = input.includes('?') ? input.slice(input.indexOf('?') + 1) : input\n return new URLSearchParams(s)\n}\n\nconst slugOrNull = (v: string | null): string | null => (v && SLUG_RE.test(v) ? v : null)\n\n/** A full org uuid becomes its 7-character tail; a tail (6–7 hex chars) is kept lowercase; anything else is dropped. */\nfunction shortOrgId(v: string | null | undefined): string | null {\n if (!v) return null\n const tail = orgIdTail(v)\n if (tail) return tail\n const t = String(v).trim().toLowerCase()\n return /^[0-9a-f]{6,7}$/.test(t) ? t : null\n}\n\n/** Reads the five parameters out of a search string, URL or URLSearchParams; the rest stays in `feature`. */\nexport function readUrlState(input: string | URLSearchParams | null | undefined): UrlState {\n const params = searchOf(input)\n const mode = params.get('mode')\n const orgRaw = params.get('org_id')\n const feature = new URLSearchParams()\n for (const [k, v] of params) if (!(URL_STATE_KEYS as readonly string[]).includes(k)) feature.append(k, v)\n return {\n mode: isUrlStateMode(mode) ? mode : null,\n page: slugOrNull(params.get('page')),\n tab: slugOrNull(params.get('tab')),\n section: slugOrNull(params.get('section')),\n org_id: shortOrgId(orgRaw),\n feature,\n }\n}\n\n/** Serializes a state back to a search string: the five parameters first, in order, then the feature parameters. */\nexport function buildSearch(state: UrlState): string {\n const out = new URLSearchParams()\n for (const key of URL_STATE_KEYS) {\n const v = state[key]\n if (v) out.set(key, v)\n }\n for (const [k, v] of state.feature) out.append(k, v)\n const s = out.toString()\n return s ? `?${s}` : ''\n}\n\nexport type UrlStatePatch = Partial<Omit<UrlState, 'feature' | 'org_id'>> & {\n /** A full org uuid or a short id; written as the 7-character tail. */\n org_id?: string | null\n /** Feature parameters to set (null removes one). */\n feature?: Record<string, string | null>\n}\n\n/** Applies a change to the state carried in `current` and returns the new search string. */\nexport function writeUrlState(current: string | URLSearchParams | null | undefined, patch: UrlStatePatch): string {\n const state = readUrlState(current)\n if (patch.mode !== undefined) state.mode = patch.mode\n if (patch.page !== undefined) state.page = patch.page\n if (patch.tab !== undefined) state.tab = patch.tab\n if (patch.section !== undefined) state.section = patch.section\n if (patch.org_id !== undefined) state.org_id = shortOrgId(patch.org_id)\n if (patch.feature) {\n for (const [k, v] of Object.entries(patch.feature)) {\n if ((URL_STATE_KEYS as readonly string[]).includes(k)) continue // never through the back door\n if (v === null) state.feature.delete(k)\n else state.feature.set(k, v)\n }\n }\n return buildSearch(state)\n}\n\nexport type UrlStateDefaults = {\n /** The page's registry slug; always present (UI-Platform: \"Decides the active left-bar row\"). */\n page: string\n mode?: UrlStateMode | null\n /** The page's tabs in order; the first is the default. Omit for a page with no tab strip. */\n tabs?: readonly string[]\n /** Each tab's (or the untabbed page's, under the key \"\") sections in order; the first is the default. */\n sections?: Readonly<Record<string, readonly string[]>>\n /** Full org uuid or short id of the current organization. */\n org_id?: string | null\n}\n\nexport type Normalized = { state: UrlState; search: string; changed: boolean }\n\n/**\n * Fills a missing parameter from the defaults and corrects an unknown `tab` or\n * `section` to the default (\"filled or corrected by the shell with a replace,\n * never an error page\"). `changed` says whether the URL needs rewriting.\n */\nexport function normalizeUrlState(current: string | URLSearchParams | null | undefined, defaults: UrlStateDefaults): Normalized {\n const before = searchOf(current).toString()\n const state = readUrlState(current)\n if (!state.page) state.page = defaults.page\n if (!state.mode && defaults.mode) state.mode = defaults.mode\n if (defaults.tabs && defaults.tabs.length > 0) {\n if (!state.tab || !defaults.tabs.includes(state.tab)) state.tab = defaults.tabs[0]!\n } else {\n state.tab = null\n }\n const sectionsFor = defaults.sections?.[state.tab ?? ''] ?? (state.tab ? undefined : defaults.sections?.[''])\n if (sectionsFor && sectionsFor.length > 0) {\n if (!state.section || !sectionsFor.includes(state.section)) state.section = sectionsFor[0]!\n }\n if (!state.org_id && defaults.org_id) state.org_id = shortOrgId(defaults.org_id)\n const search = buildSearch(state)\n return { state, search, changed: search.replace(/^\\?/, '') !== before }\n}\n\n/**\n * Which history action a change needs: `mode`, `org_id`, `page` and `tab`\n * changes add an entry (Back and Forward work); a `section`-only change, or a\n * normalization, replaces the current one.\n */\nexport function historyMode(prev: UrlState, next: UrlState): 'push' | 'replace' {\n return prev.mode !== next.mode || prev.org_id !== next.org_id || prev.page !== next.page || prev.tab !== next.tab ? 'push' : 'replace'\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-portland-company/shell",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "description": "Shared chrome (header, sidebar, footer, theme, auth) for politogy apps. Drop into any Vite SPA to inherit the politogy look and signed-in user.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -51,6 +51,11 @@
51
51
  "import": "./dist/logout-signal.js",
52
52
  "require": "./dist/logout-signal.cjs"
53
53
  },
54
+ "./url-state": {
55
+ "types": "./dist/url-state.d.ts",
56
+ "import": "./dist/url-state.js",
57
+ "require": "./dist/url-state.cjs"
58
+ },
54
59
  "./app-chrome": {
55
60
  "types": "./dist/app-chrome.d.ts",
56
61
  "import": "./dist/app-chrome.js",