@tokenoftrust/storefront-runner 4.7.2 → 4.7.4

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.
@@ -0,0 +1,206 @@
1
+ ---
2
+ /**
3
+ * <ContactForm> — the `contact_form` page section. Email and message are always
4
+ * shown and required; `fields` adds optional name/phone. Submissions go to the
5
+ * platform-owned JSON endpoint (contract: lib/engagement/contact/contract.ts), so
6
+ * the section carries copy only: no recipient, endpoint or script prop.
7
+ *
8
+ * The endpoint is JSON-only, so a plain HTML post cannot reach it. The form is
9
+ * therefore revealed by script, and visitors without JS see an explanatory note
10
+ * instead of a submit button that cannot work.
11
+ *
12
+ * The endpoint is api-base-scoped (`apiBasePath`, never `basePath`, which a
13
+ * preview/candidate pin overrides with a render prefix that has no `/api` mirror).
14
+ */
15
+ import type { ProseContactFormSection } from "@/lib/content/prose";
16
+ import { withBase } from "@/lib/publication/shared/basePath";
17
+ import { CONTACT_LIMITS } from "@/lib/engagement/contact/contract";
18
+
19
+ interface Props {
20
+ section: ProseContactFormSection;
21
+ /** Distinguishes ids when several forms share a page. */
22
+ index?: number;
23
+ }
24
+ const { section, index = 0 } = Astro.props;
25
+ const { apiBasePath } = Astro.locals;
26
+ const endpoint = withBase(apiBasePath ?? "", "/api/contact");
27
+ const id = `contact-form-${index}`;
28
+ const fields = section.fields ?? [];
29
+ const messageLabel = section.messageLabel ?? "How can we help?";
30
+ const submitLabel = section.submitLabel ?? "Send message";
31
+ const successMessage = section.successMessage ?? "Thanks. Your message is on its way.";
32
+ ---
33
+ <section
34
+ class="contact-form"
35
+ data-contact-form
36
+ data-endpoint={endpoint}
37
+ data-success={successMessage}
38
+ aria-labelledby={section.heading ? `${id}-heading` : undefined}
39
+ >
40
+ {section.heading && <h2 id={`${id}-heading`} class="contact-form__heading">{section.heading}</h2>}
41
+ <noscript>
42
+ <p class="contact-form__note">This contact form needs JavaScript. Enable it and reload, or use the contact details on this page.</p>
43
+ </noscript>
44
+ <form hidden novalidate data-contact-form-el>
45
+ {fields.includes("name") && (
46
+ <div class="contact-form__field">
47
+ <label for={`${id}-name`}>Name</label>
48
+ <input id={`${id}-name`} name="name" type="text" autocomplete="name" maxlength={CONTACT_LIMITS.name} />
49
+ </div>
50
+ )}
51
+ <div class="contact-form__field">
52
+ <label for={`${id}-email`}>Email</label>
53
+ <input id={`${id}-email`} name="email" type="email" inputmode="email" autocomplete="email" required maxlength={CONTACT_LIMITS.email} />
54
+ </div>
55
+ {fields.includes("phone") && (
56
+ <div class="contact-form__field">
57
+ <label for={`${id}-phone`}>Phone</label>
58
+ <input id={`${id}-phone`} name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength={CONTACT_LIMITS.phone} />
59
+ </div>
60
+ )}
61
+ <div class="contact-form__field">
62
+ <label for={`${id}-message`}>{messageLabel}</label>
63
+ <textarea id={`${id}-message`} name="message" rows="6" required maxlength={CONTACT_LIMITS.message}></textarea>
64
+ </div>
65
+ <div class="contact-form__trap" aria-hidden="true">
66
+ <input name="company" type="text" tabindex="-1" autocomplete="off" />
67
+ </div>
68
+ <button type="submit" class="contact-form__submit">{submitLabel}</button>
69
+ </form>
70
+ <p class="contact-form__status" role="status" aria-live="polite" data-contact-form-status></p>
71
+ </section>
72
+
73
+ <style>
74
+ .contact-form { margin-top: 1.5rem; }
75
+ .contact-form__heading { font-family: var(--font-display, inherit); font-size: var(--heading-h2, 1.5rem); font-weight: 600; }
76
+ .contact-form__field { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 1rem; }
77
+ .contact-form__field label { font-weight: 600; }
78
+ .contact-form__field input,
79
+ .contact-form__field textarea {
80
+ width: 100%;
81
+ padding: 0.65rem 0.8rem;
82
+ border: 1px solid var(--color-border);
83
+ border-radius: var(--radius-md, 0.375rem);
84
+ background: var(--color-surface);
85
+ color: var(--color-text);
86
+ font: inherit;
87
+ }
88
+ .contact-form__field input[aria-invalid="true"],
89
+ .contact-form__field textarea[aria-invalid="true"] { border-color: var(--color-danger, currentColor); }
90
+ .contact-form__field input:focus-visible,
91
+ .contact-form__field textarea:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
92
+ .contact-form__trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
93
+ .contact-form__submit {
94
+ margin-top: 1.25rem;
95
+ min-height: var(--tap-target, 44px);
96
+ padding: 0.65rem 1.4rem;
97
+ border: 0;
98
+ border-radius: var(--radius-md, 0.375rem);
99
+ background: var(--color-accent);
100
+ color: var(--color-accent-contrast, var(--color-primary-contrast));
101
+ font: inherit;
102
+ font-weight: 700;
103
+ cursor: pointer;
104
+ }
105
+ .contact-form__submit[disabled] { opacity: 0.6; cursor: progress; }
106
+ .contact-form__status { margin-top: 1rem; min-height: 1.5em; color: var(--color-text); }
107
+ .contact-form__status[data-state="error"] { color: var(--color-danger, var(--color-text)); font-weight: 600; }
108
+ .contact-form__note { color: var(--color-muted); }
109
+ </style>
110
+
111
+ <script>
112
+ import {
113
+ CONTACT_TOKEN_MAX_AGE_MS,
114
+ CONTACT_TOKEN_MIN_AGE_MS,
115
+ fetchContactToken,
116
+ submitContactMessage,
117
+ type ContactErrorCode,
118
+ type ContactField,
119
+ } from "@/lib/engagement/contact/contract";
120
+
121
+ const MESSAGES: Partial<Record<ContactErrorCode, string>> = {
122
+ rate_limited: "You have sent a few messages already. Wait a minute and try again.",
123
+ invalid_input: "Please check the highlighted field and try again.",
124
+ unavailable: "Messages can't be received right now. Your text is still here; try again shortly.",
125
+ store_failed: "We couldn't save your message. Your text is still here; try again.",
126
+ too_large: "That message is too long. Shorten it and try again.",
127
+ };
128
+ const GENERIC = "Something went wrong. Your text is still here; try again.";
129
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
130
+
131
+ function setup(root: HTMLElement) {
132
+ const form = root.querySelector<HTMLFormElement>("[data-contact-form-el]");
133
+ const status = root.querySelector<HTMLElement>("[data-contact-form-status]");
134
+ const endpoint = root.dataset.endpoint;
135
+ if (!form || !status || !endpoint) return;
136
+ // The wire contract appends /api/contact itself; hand it the api base.
137
+ const apiBase = endpoint.replace(/\/api\/contact$/, "");
138
+ const say = (text: string, state: "ok" | "error" | "") => {
139
+ status.textContent = text;
140
+ status.dataset.state = state;
141
+ };
142
+ let token: { value: string; at: number } | null = null;
143
+ const loadToken = async () => {
144
+ token = { value: await fetchContactToken(apiBase), at: Date.now() };
145
+ };
146
+ const ready = loadToken();
147
+ form.hidden = false;
148
+ ready.catch(() => say("Messages can't be received right now. Try again shortly.", "error"));
149
+
150
+ form.addEventListener("submit", async (e) => {
151
+ e.preventDefault();
152
+ const button = form.querySelector<HTMLButtonElement>("button[type=submit]");
153
+ const value = (name: string) =>
154
+ (form.elements.namedItem(name) as HTMLInputElement | null)?.value.trim() ?? "";
155
+ form.querySelectorAll("[aria-invalid]").forEach((el) => el.removeAttribute("aria-invalid"));
156
+ const flag = (field: ContactField) => {
157
+ const el = form.elements.namedItem(field) as HTMLElement | null;
158
+ el?.setAttribute("aria-invalid", "true");
159
+ el?.focus();
160
+ };
161
+ if (!value("email") || !/^\S+@\S+$/.test(value("email"))) {
162
+ flag("email");
163
+ return say("Enter a valid email address.", "error");
164
+ }
165
+ if (!value("message")) {
166
+ flag("message");
167
+ return say("Enter a message.", "error");
168
+ }
169
+ if (button) button.disabled = true;
170
+ say("Sending...", "");
171
+ try {
172
+ if (!token || Date.now() - token.at > CONTACT_TOKEN_MAX_AGE_MS - 60_000) await loadToken();
173
+ const age = Date.now() - token!.at;
174
+ if (age < CONTACT_TOKEN_MIN_AGE_MS) await wait(CONTACT_TOKEN_MIN_AGE_MS - age + 100);
175
+ const name = value("name");
176
+ const phone = value("phone");
177
+ const result = await submitContactMessage(apiBase, {
178
+ token: token!.value,
179
+ ...(name ? { name } : {}),
180
+ email: value("email"),
181
+ ...(phone ? { phone } : {}),
182
+ message: value("message"),
183
+ company: (form.elements.namedItem("company") as HTMLInputElement | null)?.value ?? "",
184
+ });
185
+ if (result.body.ok) {
186
+ form.reset();
187
+ token = null;
188
+ say(root.dataset.success ?? "Thanks. Your message is on its way.", "ok");
189
+ loadToken().catch(() => {});
190
+ return;
191
+ }
192
+ const code = result.body.error;
193
+ if (code === "invalid_token" || code === "forbidden_origin") token = null;
194
+ if (result.body.field) flag(result.body.field);
195
+ say(MESSAGES[code] ?? GENERIC, "error");
196
+ if (!token) loadToken().catch(() => {});
197
+ } catch {
198
+ say(GENERIC, "error");
199
+ } finally {
200
+ if (button) button.disabled = false;
201
+ }
202
+ });
203
+ }
204
+
205
+ document.querySelectorAll<HTMLElement>("[data-contact-form]").forEach(setup);
206
+ </script>
@@ -16,6 +16,7 @@ import type { ProseSection } from "@/lib/content/prose";
16
16
  import { normalizeProse, withHeadingIds } from "@/lib/content/prose";
17
17
  import RichText from "@/components/content/storefront/content/RichText.astro";
18
18
  import Callout from "@/components/content/storefront/content/Callout.astro";
19
+ import ContactForm from "@/components/content/storefront/content/ContactForm.astro";
19
20
  import NeedsReviewCallout from "@/components/content/storefront/content/NeedsReviewCallout.astro";
20
21
 
21
22
  interface Props {
@@ -28,7 +29,7 @@ const rendered = withHeadingIds(normalizeProse(sections));
28
29
  ---
29
30
  <div class="prose-sections max-w-[68ch] text-[var(--color-text)]" data-widget="prose-sections">
30
31
  {
31
- rendered.map((s) => {
32
+ rendered.map((s, i) => {
32
33
  switch (s.kind) {
33
34
  case "heading":
34
35
  return (s.level ?? 2) === 3 ? (
@@ -109,6 +110,8 @@ const rendered = withHeadingIds(normalizeProse(sections));
109
110
  )}
110
111
  </address>
111
112
  );
113
+ case "contact_form":
114
+ return <ContactForm section={s} index={i} />;
112
115
  case "needs_review":
113
116
  return includeNeedsReview ? (
114
117
  <NeedsReviewCallout flag={{ reason: s.reason, severity: s.severity, owner: s.owner, href: s.href }} />
@@ -65,6 +65,20 @@ export interface ProseContactSection {
65
65
  address?: string[];
66
66
  }
67
67
 
68
+ /**
69
+ * A working contact form. Email and message are always shown and required;
70
+ * `fields` adds the optional name/phone inputs. The destination, endpoint and
71
+ * script are platform-owned: this section carries copy only.
72
+ */
73
+ export interface ProseContactFormSection {
74
+ kind: "contact_form";
75
+ heading?: string;
76
+ fields?: ("name" | "phone")[];
77
+ messageLabel?: string;
78
+ submitLabel?: string;
79
+ successMessage?: string;
80
+ }
81
+
68
82
  /** A needs-review flag embedded in the flow (see needsReview.ts). */
69
83
  export type ProseNeedsReviewSection = { kind: "needs_review" } & NeedsReview;
70
84
 
@@ -75,6 +89,7 @@ export type ProseSection =
75
89
  | ProseDefinitionsSection
76
90
  | ProseCalloutSection
77
91
  | ProseContactSection
92
+ | ProseContactFormSection
78
93
  | ProseNeedsReviewSection;
79
94
 
80
95
  /** A full structured page (policy / about / compliance). */
@@ -129,6 +144,9 @@ export function normalizeProse(
129
144
  case "contact":
130
145
  if (s.email || s.phone || (s.address && s.address.length)) out.push(s);
131
146
  break;
147
+ case "contact_form":
148
+ out.push(normalizeContactForm(s));
149
+ break;
132
150
  case "needs_review":
133
151
  if (s.reason.trim()) out.push(s);
134
152
  break;
@@ -137,6 +155,28 @@ export function normalizeProse(
137
155
  return out;
138
156
  }
139
157
 
158
+ const copy = (v: unknown): string | undefined =>
159
+ typeof v === "string" && v.trim() ? v.trim() : undefined;
160
+
161
+ /** Rebuild from the known copy props only: unknown props (a recipient, an endpoint) never survive. */
162
+ function normalizeContactForm(s: ProseContactFormSection): ProseContactFormSection {
163
+ const raw: unknown = s.fields;
164
+ const fields = (["name", "phone"] as const).filter(
165
+ (f) => Array.isArray(raw) && raw.includes(f),
166
+ );
167
+ const out: ProseContactFormSection = { kind: "contact_form" };
168
+ const heading = copy(s.heading);
169
+ const messageLabel = copy(s.messageLabel);
170
+ const submitLabel = copy(s.submitLabel);
171
+ const successMessage = copy(s.successMessage);
172
+ if (heading) out.heading = heading;
173
+ if (fields.length) out.fields = [...fields];
174
+ if (messageLabel) out.messageLabel = messageLabel;
175
+ if (submitLabel) out.submitLabel = submitLabel;
176
+ if (successMessage) out.successMessage = successMessage;
177
+ return out;
178
+ }
179
+
140
180
  /** Fill in a derived `id` for every heading that lacks one (for TOC anchoring). */
141
181
  export function withHeadingIds(
142
182
  sections: ReadonlyArray<ProseSection>,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.7.2",
3
+ "version": "4.7.4",
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",
@@ -255,8 +255,9 @@ function pageShape(value, file) {
255
255
  case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
256
256
  case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
257
257
  case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
258
+ case "contact_form": if (["heading", "messageLabel", "submitLabel", "successMessage"].some((key) => section[key] !== undefined && !string(section[key])) || section.fields !== undefined && (!Array.isArray(section.fields) || section.fields.some((field) => field !== "name" && field !== "phone"))) findings.push(finding("parity-section-shape", at, "contact_form requires optional string copy and fields limited to name or phone", "supply a valid contact_form section")); break;
258
259
  case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
259
- default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
260
+ default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, contact_form, or needs_review"));
260
261
  }
261
262
  });
262
263
  const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
@@ -5,7 +5,8 @@
5
5
  * the renderer-published boot descriptor global, guarantees ONE lifecycle per document
6
6
  * with a window-level singleton (so a nested layout that re-runs the boot island cannot
7
7
  * double-init), wires same-document navigations to the reserved completed-navigation
8
- * event, disposes on document teardown, and is a guarded no-op off the browser.
8
+ * event, disposes on every `pagehide`, boots a fresh lifecycle when the back-forward
9
+ * cache restores the page, and is a guarded no-op off the browser.
9
10
  *
10
11
  * The entry module for each integration is the same-origin, exact-version asset the
11
12
  * boot descriptor carries (`integration.entry.url`): the default loader dynamically
@@ -440,20 +441,38 @@ function deferFetchCapability(
440
441
  })) as typeof fetch;
441
442
  }
442
443
 
444
+ /** The window property marking that this window's event wiring is installed (once per window). */
445
+ const LIFECYCLE_WIRED_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE_WIRED__";
446
+
443
447
  /**
444
- * Boot the lifecycle for `win` exactly once. A second call returns the same instance
445
- * (nested-layout safety). Returns `null` when there is no boot descriptor to act on.
448
+ * Boot the lifecycle for `win`. While a live lifecycle exists a second call returns it
449
+ * (nested-layout safety). A disposed one — the page was hidden into the back-forward
450
+ * cache — is replaced by a fresh lifecycle. Returns `null` when there is no boot
451
+ * descriptor to act on.
446
452
  */
447
453
  export function bootIntegrationLifecycle(
448
454
  win: LifecycleWindow,
449
455
  options: BootLifecycleOptions = {},
450
456
  ): IntegrationLifecycle | null {
451
457
  const existing = win[LIFECYCLE_SINGLETON_GLOBAL];
452
- if (existing instanceof IntegrationLifecycle) return existing;
458
+ if (existing instanceof IntegrationLifecycle && !existing.isDisposed) return existing;
453
459
 
454
460
  const descriptor = readDescriptor(win);
455
461
  if (!descriptor) return null;
456
462
 
463
+ const lifecycle = createDocumentLifecycle(win, descriptor, options);
464
+ win[LIFECYCLE_SINGLETON_GLOBAL] = lifecycle;
465
+ wireWindow(win, options);
466
+ void lifecycle.init();
467
+ return lifecycle;
468
+ }
469
+
470
+ function createDocumentLifecycle(
471
+ win: LifecycleWindow,
472
+ descriptor: IntegrationBootDescriptor,
473
+ options: BootLifecycleOptions,
474
+ ): IntegrationLifecycle {
475
+
457
476
  const hostFetch = options.fetchCapability ??
458
477
  (typeof win.fetch === "function" ? win.fetch.bind(win) : undefined);
459
478
  const schedulePlatformAdapterStart =
@@ -508,7 +527,7 @@ export function bootIntegrationLifecycle(
508
527
  // The managed-analytics bridge over the SAME broker the adapters subscribe to — the
509
528
  // only seam that emits this document's governed page view. The lifecycle owns when it
510
529
  // fires (after every adapter has subscribed, and re-attempted on a consent grant).
511
- const lifecycle = new IntegrationLifecycle({
530
+ return new IntegrationLifecycle({
512
531
  descriptor,
513
532
  loadModule,
514
533
  isEligible,
@@ -536,39 +555,48 @@ export function bootIntegrationLifecycle(
536
555
  pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
537
556
  },
538
557
  });
539
- win[LIFECYCLE_SINGLETON_GLOBAL] = lifecycle;
558
+ }
540
559
 
541
- wireNavigation(win, lifecycle);
542
- wireTeardown(win, lifecycle);
543
- void lifecycle.init();
544
- return lifecycle;
560
+ /** The window's current live lifecycle, if any. */
561
+ function liveLifecycle(win: LifecycleWindow): IntegrationLifecycle | undefined {
562
+ const current = win[LIFECYCLE_SINGLETON_GLOBAL];
563
+ return current instanceof IntegrationLifecycle && !current.isDisposed ? current : undefined;
545
564
  }
546
565
 
547
566
  /**
548
- * Wire same-document navigations to the reserved completed-navigation event. In a
549
- * traditional MPA a real document load re-inits by construction, so only the two
550
- * navigations that DON'T reload the document — hash changes and history back/forward —
551
- * need a completed-navigation signal, and neither re-initializes integrations.
567
+ * Install this window's event wiring once; every listener acts on the CURRENT lifecycle.
568
+ *
569
+ * Same-document navigations — hash changes and history back/forward — emit the reserved
570
+ * completed-navigation event and never re-initialize integrations; a real document load
571
+ * re-inits by construction.
572
+ *
573
+ * Every `pagehide` disposes the lifecycle, so integrations clean up and abandon any
574
+ * deferred work (a held navigation never resumes after the shopper left). A
575
+ * `pageshow` from the back-forward cache boots a fresh lifecycle, so the restored page
576
+ * has live integrations again.
552
577
  */
553
- function wireNavigation(win: LifecycleWindow, lifecycle: IntegrationLifecycle): void {
578
+ function wireWindow(win: LifecycleWindow, options: BootLifecycleOptions): void {
579
+ if (win[LIFECYCLE_WIRED_GLOBAL] === true) return;
580
+ win[LIFECYCLE_WIRED_GLOBAL] = true;
554
581
  let lastPath = win.location.pathname + win.location.hash;
555
582
  win.addEventListener("hashchange", () => {
556
583
  const to = win.location.pathname + win.location.hash;
557
- lifecycle.notifyNavigationCompleted({ from: lastPath, to, kind: "hash" });
584
+ liveLifecycle(win)?.notifyNavigationCompleted({ from: lastPath, to, kind: "hash" });
558
585
  lastPath = to;
559
586
  });
560
587
  win.addEventListener("popstate", () => {
561
588
  const to = win.location.pathname + win.location.hash;
562
589
  const kind: NavigationKind = "back-forward";
563
- lifecycle.notifyNavigationCompleted({ from: lastPath, to, kind });
590
+ liveLifecycle(win)?.notifyNavigationCompleted({ from: lastPath, to, kind });
564
591
  lastPath = to;
565
592
  });
566
- }
567
-
568
- /** Dispose the lifecycle when the document is torn down, so integrations clean up once. */
569
- function wireTeardown(win: LifecycleWindow, lifecycle: IntegrationLifecycle): void {
570
593
  win.addEventListener("pagehide", () => {
571
- void lifecycle.dispose();
594
+ const current = win[LIFECYCLE_SINGLETON_GLOBAL];
595
+ if (current instanceof IntegrationLifecycle) void current.dispose();
596
+ });
597
+ win.addEventListener("pageshow", (event) => {
598
+ if ((event as { persisted?: unknown } | null)?.persisted !== true) return;
599
+ bootIntegrationLifecycle(win, options);
572
600
  });
573
601
  }
574
602
 
@@ -224,6 +224,11 @@ export class IntegrationLifecycle {
224
224
  return this.state === "initialized";
225
225
  }
226
226
 
227
+ /** True once dispose has started; a disposed lifecycle never runs again. */
228
+ get isDisposed(): boolean {
229
+ return this.isTerminal();
230
+ }
231
+
227
232
  /**
228
233
  * Initialize every integration EXACTLY ONCE per document, in the plan's resolved order.
229
234
  * A second call — including one from a nested layout that re-runs the boot island — is a