@tokenoftrust/storefront-runner 2.2.78 → 2.2.80

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.78",
3
+ "version": "2.2.80",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -797,6 +797,49 @@ function renderControlState(root: HTMLElement, state: ViewerAnalyticsConsentStat
797
797
  }
798
798
  }
799
799
 
800
+ function firstEnabledButton(root: HTMLElement): HTMLButtonElement | null {
801
+ return root.querySelector<HTMLButtonElement>("button:not(:disabled)");
802
+ }
803
+
804
+ /**
805
+ * Bind each persistent "Analytics choices" disclosure to the control its
806
+ * `aria-controls` names: a click opens the control and moves focus into it; a second
807
+ * click, the control's Close button, or Escape inside it closes it again, and the
808
+ * latter two return focus to the toggle.
809
+ */
810
+ function bindViewerAnalyticsChoicesToggles(win: ViewerConsentWindow): void {
811
+ const doc = win.document;
812
+ doc?.querySelectorAll<HTMLButtonElement>("[data-tot-analytics-choices-toggle]").forEach((toggle) => {
813
+ if (toggle.dataset["totAnalyticsChoicesBound"] === "true") return;
814
+ const controlId = toggle.getAttribute("aria-controls");
815
+ const control = controlId ? doc.getElementById(controlId) : null;
816
+ if (!control) return;
817
+ toggle.dataset["totAnalyticsChoicesBound"] = "true";
818
+
819
+ const open = () => {
820
+ control.hidden = false;
821
+ toggle.setAttribute("aria-expanded", "true");
822
+ firstEnabledButton(control)?.focus();
823
+ };
824
+ const close = (returnFocus: boolean) => {
825
+ control.hidden = true;
826
+ toggle.setAttribute("aria-expanded", "false");
827
+ if (returnFocus) toggle.focus();
828
+ };
829
+
830
+ toggle.addEventListener("click", () => (control.hidden ? open() : close(false)));
831
+ control.addEventListener("click", (event) => {
832
+ const target = event.target;
833
+ if (target instanceof Element && target.closest("[data-tot-analytics-consent-close]")) close(true);
834
+ });
835
+ control.addEventListener("keydown", (event) => {
836
+ if (event.key !== "Escape" || control.hidden) return;
837
+ event.preventDefault();
838
+ close(true);
839
+ });
840
+ });
841
+ }
842
+
800
843
  /** Bind the renderer-owned control once; safe when a document contains no analytics plan. */
801
844
  export function bootViewerAnalyticsConsentControl(
802
845
  win: ViewerConsentWindow,
@@ -816,8 +859,11 @@ export function bootViewerAnalyticsConsentControl(
816
859
  const decision = button?.dataset["totAnalyticsConsentDecision"];
817
860
  if (decision !== "granted" && decision !== "denied") return;
818
861
  renderControlState(root, setViewerAnalyticsConsent(win, decision, now()));
862
+ // The pressed button is now disabled; keep keyboard focus inside the control.
863
+ if (button?.disabled) firstEnabledButton(root)?.focus();
819
864
  });
820
865
  });
866
+ bindViewerAnalyticsChoicesToggles(win);
821
867
 
822
868
  const storageBoundGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT_STORAGE_BOUND__";
823
869
  if (win.addEventListener && win[storageBoundGlobal] !== true) {
@@ -177,6 +177,7 @@ export function renderIntegrationPhases(
177
177
  const bodyDeferred =
178
178
  phaseBoundary("document.deferred") +
179
179
  `<div data-tot-integration-deferred hidden></div>` +
180
+ viewerAnalyticsChoicesLink(plan) +
180
181
  lifecycleHostScript();
181
182
 
182
183
  return { head, bodyStart, bodyDeferred };
@@ -234,20 +235,37 @@ function inlineScript(source: string, nonce: string): string {
234
235
 
235
236
  /** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
236
237
  function viewerAnalyticsConsentStyle(nonce: string): string {
237
- const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
238
+ const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}.tot-analytics-choices{display:flex;justify-content:center;padding:.75rem 1rem;background:var(--color-bg,#fff)}.tot-analytics-choices__toggle{border:0;background:none;padding:.25rem;color:var(--color-muted,#475569);font:500 .75rem/1.25 system-ui,sans-serif;text-decoration:underline;cursor:pointer}`;
238
239
  return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
239
240
  }
240
241
 
242
+ /** The id the persistent "Analytics choices" toggle names in `aria-controls`. */
243
+ const VIEWER_ANALYTICS_CONSENT_CONTROL_ID = "tot-analytics-consent";
244
+
241
245
  function viewerAnalyticsConsentControl(): string {
242
- return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
246
+ return `<aside id="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
243
247
  `<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
244
- `<span class="tot-analytics-consent__detail">Choose whether anonymous analytics may run.</span></span>` +
248
+ `<span class="tot-analytics-consent__detail">Choose whether analytics may run on this site.</span></span>` +
245
249
  `<span class="tot-analytics-consent__actions">` +
246
250
  `<button type="button" data-tot-analytics-consent-decision="granted" aria-pressed="false" disabled>Allow</button>` +
247
251
  `<button type="button" data-tot-analytics-consent-decision="denied" aria-pressed="true" disabled>Decline</button>` +
252
+ `<button type="button" data-tot-analytics-consent-close>Close</button>` +
248
253
  `</span></aside>`;
249
254
  }
250
255
 
256
+ /**
257
+ * The persistent, platform-owned way to reach the control: a disclosure button placed
258
+ * after the tenant's own footer, on every page whose plan asks for analytics consent.
259
+ * The lifecycle host binds it (`bootViewerAnalyticsConsentControl`); no inline handler.
260
+ */
261
+ function viewerAnalyticsChoicesLink(plan: EffectivePlan): string {
262
+ if (!plan.consentNeeds.includes("analytics")) return "";
263
+ return `<div class="tot-analytics-choices">` +
264
+ `<button type="button" class="tot-analytics-choices__toggle" data-tot-analytics-choices-toggle ` +
265
+ `aria-controls="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" aria-expanded="false">Analytics choices</button>` +
266
+ `</div>`;
267
+ }
268
+
251
269
  /**
252
270
  * Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
253
271
  * CSP header value) with one fresh per-response `nonce`. Idempotent on text with no