New:Socket for Asana Is Now Available.Learn more
Get Started

@expofp/config

Package Overview
Dependencies
Maintainers
7
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@expofp/config - npm Package Compare versions

Comparing version
3.22.0
to
3.23.0
+33
dist/lib/deprecated-url-params.d.ts
/**
* TEMPORARY compat shim for the deprecated `?noOverlay=`, `?allowConsent=` and
* `?agenda`, translated into config fields while integrators move to the
* canonical `?visibility[overlay]=`, `?consent=` and `?showList=sessions`.
*
* A module of its own, outside `UrlConfigSchema`, so the whole shim retires in
* one move: delete this file and the compiler points at every consumer; the
* keys then join `retiredKeys` in the legacy codec. `?agenda` broke when the
* panel was renamed to sessions with no URL alias left (b227ed255, Oct 2025) —
* installed mobile-SDK apps emit the old spelling, and no SDK release can fix
* them; this entry is the missing alias.
*
* Precedence: an alias is dropped when the same query carries its canonical
* key, resolved HERE rather than by merge order (`noOverlay` lands in a
* different field than `visibility[overlay]`, one the FloorPlan latches at
* load), so both consumers apply the slice without precedence logic.
*/
import { type Config, type Consent, type Intent, type Visibility } from '@expofp/schema';
/** The deprecated keys for the legacy codec's partition — derived, the schema is the only list. */
export declare const DEPRECATED_URL_KEYS: string[];
/**
* The canonical readings the aliases defer to — callers that already parsed
* the query pass theirs in (`intents`: every canonical intent, bracketed
* entries before shortcut keys); otherwise the shim reads the query itself.
*/
export interface CanonicalAliasTargets {
visibility?: Visibility;
consent?: Consent;
intents?: Intent[];
}
/** The deprecated query params as a config slice; `{}` when the URL has none. */
export declare function parseDeprecatedUrlParams(url: string | URL, canonical?: CanonicalAliasTargets): Partial<Config>;
//# sourceMappingURL=deprecated-url-params.d.ts.map
/**
* TEMPORARY compat shim for the deprecated `?noOverlay=`, `?allowConsent=` and
* `?agenda`, translated into config fields while integrators move to the
* canonical `?visibility[overlay]=`, `?consent=` and `?showList=sessions`.
*
* A module of its own, outside `UrlConfigSchema`, so the whole shim retires in
* one move: delete this file and the compiler points at every consumer; the
* keys then join `retiredKeys` in the legacy codec. `?agenda` broke when the
* panel was renamed to sessions with no URL alias left (b227ed255, Oct 2025) —
* installed mobile-SDK apps emit the old spelling, and no SDK release can fix
* them; this entry is the missing alias.
*
* Precedence: an alias is dropped when the same query carries its canonical
* key, resolved HERE rather than by merge order (`noOverlay` lands in a
* different field than `visibility[overlay]`, one the FloorPlan latches at
* load), so both consumers apply the slice without precedence logic.
*/
import { UrlConfigSchema, } from '@expofp/schema';
import * as z from 'zod';
import { parseFromUrlTolerant } from './url-codec.js';
import { parseIntentsFromUrl } from './url-intents.js';
/**
* Parsed through the same tolerant codec as the canonical URL slice, so the
* accepted spellings stay exactly what these params always accepted.
*/
const DeprecatedUrlSchema = z.object({
/** → `visibility.overlay` (inverted). */
noOverlay: z.boolean().optional(),
/** → `consent`: `true` → `'granted'`, `false` → `'denied'`. */
allowConsent: z.boolean().optional(),
/** → the `showList` intent on `sessions`. A string: the SDKs emit a bare `?agenda`. */
agenda: z.string().optional(),
});
/** The deprecated keys for the legacy codec's partition — derived, the schema is the only list. */
export const DEPRECATED_URL_KEYS = Object.keys(DeprecatedUrlSchema.shape);
/** The deprecated query params as a config slice; `{}` when the URL has none. */
export function parseDeprecatedUrlParams(url, canonical = parseCanonicalTargets(url)) {
const { value } = parseFromUrlTolerant(DeprecatedUrlSchema, url);
const slice = {};
// stays the `noOverlay` config field: routing through it leaves the
// visibility node untouched — exactly what this param always did
if (value.noOverlay !== undefined && canonical.visibility?.overlay === undefined) {
slice.noOverlay = value.noOverlay;
}
if (value.allowConsent !== undefined && canonical.consent === undefined) {
slice.consent = value.allowConsent ? 'granted' : 'denied';
}
// the panel is `sessions` — and any canonical list panel wins: two
// `showList` intents would play in sequence, the alias taking the screen
if (value.agenda !== undefined && !opensList(canonical.intents)) {
slice.intents = [{ name: 'showList', args: ['sessions'] }];
}
return slice;
}
function opensList(intents) {
return (intents ?? []).some((intent) => intent.name === 'showList');
}
/** The canonical keys each alias defers to, read from the same query. */
const CanonicalUrlSchema = UrlConfigSchema.pick({
visibility: true,
consent: true,
intents: true,
});
/** Self-parse fallback: the same canonical readings, from the query itself. */
function parseCanonicalTargets(url) {
const { value } = parseFromUrlTolerant(CanonicalUrlSchema, url);
return {
visibility: value.visibility,
consent: value.consent,
intents: [...(value.intents ?? []), ...parseIntentsFromUrl(url).intents],
};
}
+1
-0
export { applyIntents } from './lib/apply-intents.js';
export { getConfig, setConfig } from './lib/config-store.js';
export { type DebugSettingDescriptor, toDebugSettings } from './lib/debug-settings.js';
export { parseDeprecatedUrlParams } from './lib/deprecated-url-params.js';
export { type LegacyOneShot, type LegacyPrimary, type LegacyUrlQuery, parseLegacyQuery, serializeSelection, splitPlatformParams, type UrlSelection, } from './lib/legacy-url.js';

@@ -5,0 +6,0 @@ export { loadConfig } from './lib/load-config.js';

export { applyIntents } from './lib/apply-intents.js';
export { getConfig, setConfig } from './lib/config-store.js';
export { toDebugSettings } from './lib/debug-settings.js';
// TEMPORARY, with the shim itself — the floor plan needs it for the runtime
// `applyParameters` path, which parses the query a second time
export { parseDeprecatedUrlParams } from './lib/deprecated-url-params.js';
export { parseLegacyQuery, serializeSelection, splitPlatformParams, } from './lib/legacy-url.js';

@@ -5,0 +8,0 @@ export { loadConfig } from './lib/load-config.js';

+5
-24

@@ -30,4 +30,8 @@ /**

* `packages/floorplan/src/services/url-dispatch.ts` (lint-enforced).
*
* Keys whose capability has been retired keep one line each in
* {@link retiredKeys} — no translation, just enough for an old link to degrade
* to a clean plan instead of replaying as search text.
*/
import { type Camera, type ListPanel } from '@expofp/schema';
import { type ListPanel } from '@expofp/schema';
/** The list panels addressable by keyword (`?bookmarks`, `?sessions`, …). */

@@ -62,18 +66,2 @@ export type LegacyListPanel = ListPanel;

} | {
type: 'language';
langId?: string;
} | {
type: 'printPdf';
} | {
type: 'visibility';
hidden: string[];
} | {
type: 'buildRoute';
} | {
type: 'pathway';
pathwayId: string;
boothIds: string[];
exhibitorIds: string[];
tourId?: string;
} | {
type: 'selectExhibitors';

@@ -117,9 +105,2 @@ values: string[];

type: 'resetUiScale';
} | {
type: 'legacyBookmarks';
appendExhibitorId: number | null;
} | {
type: 'camera';
camera: Camera;
roll?: number;
};

@@ -126,0 +107,0 @@ export interface LegacyUrlQuery {

@@ -30,5 +30,10 @@ /**

* `packages/floorplan/src/services/url-dispatch.ts` (lint-enforced).
*
* Keys whose capability has been retired keep one line each in
* {@link retiredKeys} — no translation, just enough for an old link to degrade
* to a clean plan instead of replaying as search text.
*/
import { IntentsSchema, UrlConfigSchema } from '@expofp/schema';
import { safeDecode } from '@expofp/utils';
import { DEPRECATED_URL_KEYS } from './deprecated-url-params.js';
/* ── Parse: location.search → commands ─────────────────────────────────────── */

@@ -43,8 +48,83 @@ /** Query keys consumed by `loadConfig` before dispatch: config slice + intent shortcuts. */

/**
* The legacy camera params, translated into `config.camera`'s shape. Stripped
* from the slug only in their `key=value` form — a bare slug that merely spells
* one of these words (a booth named "center") still resolves via the catalog.
* RETIRED keys — capability gone, only swallowed (in `key=value` form: a bare
* slug that merely spells one of these words still resolves via the catalog),
* so an old link opens a clean plan instead of replaying as search text. The
* list only ever shrinks; a parameter that still DOES something belongs in
* `UrlConfigSchema` or `IntentsSchema`, never here.
*
* | retired | canonical form today |
* | ---------------------- | ------------------------------- |
* | `centerxy=x,y` | `camera[x]` / `camera[y]` |
* | `center=lat,lng` | `camera[lat]` / `camera[lng]` |
* | `z=` | `camera[floor]` |
* | `bearing=` | `camera[bearing]` |
* | `zoomtime=` | `camera[zoomTime]` |
* | `roll=` | — internal renderer knob |
* | `hide=a,b` | `visibility[a]=false` |
* | `lang=` | `changeLanguage=` |
* | `preview=` | — the preview link needs none |
* | `copy_exh=` | — a removed development seed |
* | `pathway=id` | `showPathway[0]=id` |
* | `booths=a,b` | — part of `showPathway` |
* | `b=` / `ba=` | — bookmark import, removed |
* | `-pdf` | — a print mode that never was |
* | `build-route` | `startBuildRoute` |
* | `sw=` / `yah=` / `k=` | — readers live on in legacy |
* | `kiosk_<framing>=` | `kiosk[…]` — the anchor node |
*
* `sw`/`yah`/`k` still have readers in the legacy runtime (service-worker
* toggle, YAH-marker command, saved-kiosk id) but none here — the mobile SDK
* enums emit them and they die out with app installs; their sibling `?agenda`
* is translated by the shim instead. `kiosk_<framing>` is the 11 fields the
* old 14-key QR channel spoke beyond the surviving `kiosk_x/y/z` (those
* partition via {@link directReadKeys}); unswallowed, one replayed as search
* text — and beside a route slug its tail fused into the last colon-part,
* silently flipping the accessible flag. `tour=`
* keeps its live meaning (`selectTour` covers what the pathway link used it
* for), and retiring `b=`/`ba=` as plain keys drops their emulated query wipe:
* one-shots beside them now apply.
*/
const legacyCameraKeys = new Set(['centerxy', 'center', 'z', 'bearing', 'zoomtime', 'roll']);
const retiredKeys = new Set([
'centerxy',
'center',
'z',
'bearing',
'zoomtime',
'roll',
'hide',
'lang',
'preview',
'copy_exh',
'pathway',
'booths',
'b',
'ba',
'sw',
'yah',
'k',
// the framing/cosmetic 11 of the old 14-key kiosk QR channel — `kiosk_x/y/z`
// live on in directReadKeys, and the field names mirror KioskSchema
'kiosk_heading',
'kiosk_lat',
'kiosk_lng',
'kiosk_iconSizePercent',
'kiosk_uiScale',
'kiosk_mapRollDegrees',
'kiosk_mapInitialPtScale',
'kiosk_mapPtScale',
'kiosk_mapCenterX',
'kiosk_mapCenterY',
'kiosk_mapPitch',
]);
/** Retired keywords: a whole-slug word rather than a `key=value` pair. */
const retiredKeywords = new Set(['-pdf', 'build-route']);
/**
* The deprecated aliases the `loadConfig` compat shim still consumes — config
* keys as far as this codec is concerned, so they partition with
* `configOwnedKeys` and match in both spellings (which is what catches the
* SDKs' valueless `?agenda`, at the cost of a booth slugged `agenda`).
* TEMPORARY: when the shim goes, move these into `retiredKeys`.
*/
const deprecatedKeys = new Set(DEPRECATED_URL_KEYS);
/**
* Query keys owned by the DELIVERY PLATFORM, not the floor plan: `?expo=` is

@@ -66,2 +146,25 @@ * the routing param the sha-delivery platform materializes for expo-aware

const platformParamKeys = new Set(['expo']);
/**
* Query keys the floor plan reads STRAIGHT from `location.search`: `?layer=`
* (init-layers) and `?kiosk_x/y/z` (the anchor a kiosk's route QR hands the
* scanning phone). Spelled out here — this package must not depend on the
* floor plan — so a reader change has to be mirrored. They partition like the
* platform's params: stripped in `key=value` form only, never `ownedByConfig`.
* Unpartitioned they replayed as search text, and the route QR's own link
* (`?route:…:true&kiosk_x=…`) lost its accessible flag — the 4th colon-part of
* a slug that had swallowed the whole `&kiosk_*` tail.
*/
const directReadKeys = new Set(['layer', 'kiosk_x', 'kiosk_y', 'kiosk_z']);
/**
* Companion keys of a primary form — read from `params` by its classifier,
* never part of the slug: `&title=` names a shared route (the route branch
* reads it; no canonical form exists). Stripped in `key=value` form only, like
* the partitions above — left in place, the tail fused into the route slug's
* last colon-part, where it silently flipped `:true` to false or ate a
* trailing FROM. A bare slug that merely spells `title` still resolves via
* the catalog. (The planner's `&source=`/`&from=` companions need no entry:
* that branch classifies on `params` before the slug is consulted, and the
* slug of a params-classified query is never replayed as search text.)
*/
const companionKeys = new Set(['title']);
export function parseLegacyQuery(search) {

@@ -72,22 +175,8 @@ const rawQuery = search.startsWith('?') ? search.slice(1) : search;

const params = new URLSearchParams(decoded);
const oneShots = collectOneShots(rawQuery, params);
// `?preview=`, `?b=`/`?ba=` (leading param) and `?copy_exh` historically
// wiped the whole query before the other one-shots could see it — only the
// heatmap flags (processed first) and the bookmark redirect itself survive
const wiped = rawQuery.startsWith('preview=') ||
rawQuery.startsWith('b=') ||
rawQuery.startsWith('ba=') ||
decoded.includes('copy_exh');
if (wiped) {
return {
primary: { type: 'select', slug: '' },
oneShots: oneShots.filter((s) => s.type === 'heatmap' || s.type === 'legacyBookmarks'),
};
}
return {
primary: classifyPrimary(residualSlug(decoded), params, hasConfigOwnedParts(decoded)),
oneShots,
primary: classifyPrimary(residualSlug(decoded), params, hasOwnedParts(decoded)),
oneShots: collectOneShots(params),
};
}
function classifyPrimary(slug, params, configOwned) {
function classifyPrimary(slug, params, owned) {
if (params.has('kiosk')) {

@@ -126,5 +215,2 @@ const value = params.get('kiosk');

return { type: 'list', list: 'language' };
if (params.has('lang')) {
return { type: 'language', langId: params.get('lang')?.toLowerCase() };
}
if (slug === 'sessions')

@@ -136,32 +222,21 @@ return { type: 'list', list: 'sessions' };

return { type: 'list', list: 'speakers' };
if (slug === '-pdf')
return { type: 'printPdf' };
if (slug.startsWith('hide')) {
const hidden = (new URLSearchParams(slug).get('hide') ?? '').split(',').filter(Boolean);
return { type: 'visibility', hidden };
if (slug.startsWith('exhibitors=')) {
const value = slug.slice('exhibitors='.length);
// `?exhibitors=` and `?exhibitors=true` are exactly the two values the
// FILTER manager reads as "open the exhibitors list" — classified as a
// selection, the link also selected an exhibitor named "true". Anything
// else after `exhibitors=` is a display-name list.
if (value === '' || value === 'true')
return { type: 'ownedByFilters' };
return { type: 'selectExhibitors', values: value.split(',') };
}
if (slug === 'build-route')
return { type: 'buildRoute' };
const pathwayId = params.get('pathway');
if (pathwayId) {
return {
type: 'pathway',
pathwayId,
boothIds: params.get('booths')?.split(',') ?? [],
exhibitorIds: params.get('exhibitors')?.split(',') ?? [],
tourId: params.get('tour') ?? undefined,
};
}
if (slug.startsWith('exhibitors') && slug.includes('=')) {
return { type: 'selectExhibitors', values: slug.split('=')[1].split(',') };
}
if (slug.includes('=') && (slug.startsWith('categories=') || /^poiTypes?=/.test(slug))) {
return { type: 'ownedByFilters' };
}
// Last, after every params-based branch above (`?kiosk=1&camera[x]=5` is
// still the kiosk toggle): an empty slug that got empty because config/
// intent keys owned the whole query is NOT the "clear the selection"
// command a genuinely empty query is — replaying it as `select('')` would
// close whatever is open.
if (!slug && configOwned) {
// Last, after every params-based branch above: a slug emptied because
// config/intent keys (or the deprecated aliases) owned the whole query is
// NOT the clear-selection command an empty query is — `select('')` would
// close whatever is open. Retired-only queries deliberately ARE (see
// hasOwnedParts).
if (!slug && owned) {
return { type: 'ownedByConfig' };

@@ -171,3 +246,3 @@ }

}
function collectOneShots(rawQuery, params) {
function collectOneShots(params) {
const oneShots = [];

@@ -181,10 +256,2 @@ if (params.get('heatmap') === 'true') {

}
if (rawQuery.startsWith('b=') || rawQuery.startsWith('ba=')) {
const appendRaw = new URLSearchParams(rawQuery).get('ba');
const appendExhibitorId = appendRaw === null ? null : parseInt(appendRaw, 10);
oneShots.push({
type: 'legacyBookmarks',
appendExhibitorId: Number.isFinite(appendExhibitorId) ? appendExhibitorId : null,
});
}
const blueDot = params.get('blue-dot');

@@ -229,63 +296,40 @@ if (blueDot !== null) {

}
const camera = legacyCamera(params);
if (camera) {
oneShots.push(camera);
}
return oneShots;
}
/** Translate the legacy camera params into the schema-typed `Camera` shape. */
function legacyCamera(params) {
const camera = {};
const centerxy = numberPair(params.get('centerxy'));
if (centerxy)
[camera.x, camera.y] = centerxy;
const center = numberPair(params.get('center'));
if (center)
[camera.lat, camera.lng] = center;
const floor = params.get('z');
if (floor)
camera.floor = floor;
const bearing = finiteNumber(params.get('bearing'));
if (bearing !== undefined)
camera.bearing = bearing;
const zoomTime = finiteNumber(params.get('zoomtime'));
if (zoomTime !== undefined)
camera.zoomTime = zoomTime;
const roll = finiteNumber(params.get('roll'));
if (!Object.keys(camera).length && roll === undefined)
return null;
return roll === undefined ? { type: 'camera', camera } : { type: 'camera', camera, roll };
/** A retired part, matched in `key=value` form only (see {@link retiredKeys}). */
function isRetiredPart(part) {
if (retiredKeywords.has(part))
return true;
return retiredKeys.has(part.split('=')[0]) && part.includes('=');
}
function numberPair(value) {
if (!value)
return undefined;
const parts = value.split(',').map(parseFloat);
if (parts.length !== 2 || !parts.every(Number.isFinite))
return undefined;
return [parts[0], parts[1]];
/**
* A part `loadConfig` consumes: a config-slice key, an intent shortcut, or a
* deprecated alias — bracketed sub-keys and the valueless spelling included.
*/
function isConsumedByConfig(part) {
const rawKey = part.split('=')[0];
// the legacy scalar `?heatmap=true` is slug-resident (see residualSlug)
if (rawKey === 'heatmap')
return false;
const key = rawKey.split('[')[0];
return configOwnedKeys.has(key) || deprecatedKeys.has(key);
}
function finiteNumber(value) {
if (value === null || value === '')
return undefined;
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
/**
* Whether the query holds a part the config layer consumes. Retired and
* one-shot keys deliberately do NOT count: a query made only of those means
* nothing, keeping the legacy empty select (which closes the open panel).
*/
function hasOwnedParts(decoded) {
return decoded.split('&').some((part) => !!part && isConsumedByConfig(part));
}
/**
* The slug is the decoded query minus every key another consumer owns: the
* config layer's keys and intent shortcuts (read by `loadConfig`), the
* one-shot keys consumed above, and the platform's routing params.
* `heatmap`/`type`/`subtype` stay in the slug —
* heatmap mode historically froze the URL with them in place (its data loader
* re-reads them), and the select fallback knows not to treat a query carrying
* `heatmap=true` as search text.
* retired keys swallowed above, the platform's routing params, the keys the
* floor plan reads straight from the query ({@link directReadKeys}), the
* grammar's own companion keys ({@link companionKeys}), the config layer's
* keys and intent shortcuts (read by `loadConfig`), and the one-shot keys. `heatmap`/`type`/`subtype` stay in the slug — heatmap mode historically
* froze the URL with them in place (its data loader re-reads them), and the
* select fallback knows not to treat a query carrying `heatmap=true` as search
* text.
*/
function hasConfigOwnedParts(decoded) {
return decoded.split('&').some((part) => {
const rawKey = part.split('=')[0];
// the legacy scalar `?heatmap=true` is slug-resident (see residualSlug)
if (rawKey === 'heatmap')
return false;
return configOwnedKeys.has(rawKey.split('[')[0]);
});
}
function residualSlug(decoded) {

@@ -297,16 +341,20 @@ return decoded

return false;
if (isRetiredPart(part))
return false;
const key = part.split('=')[0].split('[')[0];
if (legacyCameraKeys.has(key) && part.includes('='))
// all stripped in `key=value` form only, so a booth named "expo",
// "layer" or "title" still resolves via the catalog
if ((platformParamKeys.has(key) || directReadKeys.has(key) || companionKeys.has(key)) &&
part.includes('=')) {
return false;
if (platformParamKeys.has(key) && part.includes('='))
return false;
}
// Carve-out (retirement plan §4.8): `heatmap` is a UrlConfigSchema key
// now, so the canonical bracketed `heatmap[…]=` form is config-owned and
// strips like any other — but the legacy scalar `?heatmap=true` must
// KEEP the slug residency described above (the select fallback
// recognizes the literal `heatmap=true`; the frozen URL carries it for
// the data loader). Discriminate on the raw key: no bracket ⇒ legacy.
if (part.split('=')[0] === 'heatmap')
return true;
return !configOwnedKeys.has(key) && !oneShotKeys.has(key);
// KEEP its slug residency (the select fallback recognizes the literal
// `heatmap=true`; the frozen URL carries it for the data loader).
// `isConsumedByConfig` discriminates on the raw key: no bracket ⇒ legacy.
if (isConsumedByConfig(part))
return false;
return !oneShotKeys.has(key);
})

@@ -313,0 +361,0 @@ .join('&');

@@ -6,2 +6,3 @@ /// <reference lib="dom" />

import debug from 'debug';
import { parseDeprecatedUrlParams } from './deprecated-url-params.js';
import { parseFromStorage } from './local-storage-codec.js';

@@ -166,9 +167,20 @@ import { normalizeFpSvgLayerAliases } from './normalize-fp-svg.js';

const shortcuts = parseIntentsFromUrl(url);
if (shortcuts.intents.length) {
urlConfig.intents = [...(urlConfig.intents ?? []), ...shortcuts.intents];
}
const canonicalIntents = [...(urlConfig.intents ?? []), ...shortcuts.intents];
// TEMPORARY: the deprecated shim, handed the canonical readings parsed
// above so it does not re-parse the query. Its fields merge first so the
// canonical keys below win; its `?agenda` intent appends, since assigning
// the slice would drop what the canonical keys put there. Delete with the
// module.
const { intents: aliasIntents = [], ...deprecatedFields } = parseDeprecatedUrlParams(url, {
visibility: urlConfig.visibility,
consent: urlConfig.consent,
intents: canonicalIntents,
});
const intents = [...canonicalIntents, ...aliasIntents];
if (intents.length)
urlConfig.intents = intents;
invalidKeys.push(...shortcuts.invalidKeys);
if (invalidKeys.length)
log('loadConfig', 'ignoring invalid URL config params:', invalidKeys);
applyConsentAlias(urlConfig);
assignDefined(config, deprecatedFields);
assignDefined(config, urlConfig);

@@ -175,0 +187,0 @@ }

{
"name": "@expofp/config",
"version": "3.22.0",
"version": "3.23.0",
"type": "module",

@@ -32,6 +32,6 @@ "description": "ExpoFP SDK internal: config layer and schemas",

"zod": "4.4.3",
"@expofp/resolve": "3.22.0",
"@expofp/utils": "3.22.0",
"@expofp/schema": "3.22.0"
"@expofp/resolve": "3.23.0",
"@expofp/schema": "3.23.0",
"@expofp/utils": "3.23.0"
}
}

@@ -30,3 +30,5 @@ # @expofp/config

shared links stay bidirectional. The floor plan's `url-dispatch` service binds the commands to
stores and public methods; this codec stays pure — no DOM, no stores.
stores and public methods; this codec stays pure — no DOM, no stores. Keys whose capability has
been retired (`?hide=`, `?centerxy=`, `?lang=`, …) are listed in `retiredKeys` and swallowed, so an
old link opens a clean plan instead of replaying its query as search text.

@@ -33,0 +35,0 @@ Also: `applyIntents` (dispatch `selectBooth` / `changeLanguage` / … to the floor plan),