@spectare-personalisation/react 0.2.2 → 0.4.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/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
 
3
- type ComponentName = 'HeroStatement' | 'AtomCard' | 'StatGrid' | 'CodeBlock' | 'ComparisonTable' | 'FeatureList' | 'TestimonialCard' | 'ImageCtaHero' | 'CtaStrip';
4
- type AssemblyZone = 'hero' | 'body' | 'proof' | 'cta';
3
+ type ComponentName = 'AnnouncementBar' | 'HeroStatement' | 'AtomCard' | 'StatGrid' | 'CodeBlock' | 'ComparisonTable' | 'FeatureList' | 'TestimonialCard' | 'ImageCtaHero' | 'CtaStrip';
4
+ type AssemblyZone = 'announce' | 'hero' | 'body' | 'proof' | 'cta';
5
5
  type ComponentAssembly = Array<{
6
6
  component: ComponentName;
7
7
  slots?: Record<string, unknown>;
@@ -25,13 +25,119 @@ interface PersonalisedSectionProps {
25
25
  audience: string;
26
26
  stage: string;
27
27
  };
28
+ /**
29
+ * Seed conversion attribution with a server-rendered arrangement: pass the variantId that
30
+ * /api/assemble/ssr returned alongside initialAssembly. Without it, a visit where the client
31
+ * classification matches the server's (so no client render happens) has no variant for a
32
+ * later conversion to credit.
33
+ */
34
+ initialVariantId?: string;
35
+ /**
36
+ * Explicit visitor-typed context (e.g. a demo input box). Bypasses the session token cache
37
+ * and re-qualifies immediately with the direct flag, which the server cost-caps (cheaper
38
+ * model on a cache miss) and never writes to the shared cache.
39
+ */
40
+ directSummary?: string;
28
41
  /** Render only components assigned to this zone. */
29
42
  zone?: AssemblyZone;
30
43
  /** Alignment for statement-style components, to match the host page. */
31
44
  align?: 'left' | 'center' | 'right';
45
+ /**
46
+ * Brand accent. Passed through to ComponentRenderer, which emits it into the scoped
47
+ * token set. Defaults to #60a5fa.
48
+ */
49
+ accent?: string;
32
50
  }
33
- declare function PersonalisedSection({ orgSlug, baseUrl, brand, pageHint, className, style, initialAssembly, initialIntent, zone, align, }: PersonalisedSectionProps): react_jsx_runtime.JSX.Element | null;
51
+ declare function PersonalisedSection({ orgSlug, baseUrl, brand, pageHint, className, style, initialAssembly, initialIntent, initialVariantId, directSummary, zone, align, accent, }: PersonalisedSectionProps): react_jsx_runtime.JSX.Element | null;
34
52
 
53
+ type Align = 'left' | 'center' | 'right';
54
+ /**
55
+ * A single line above everything else. Deliberately the plainest component in the set: no
56
+ * heading, no image, no padding to speak of. A bar that draws attention to itself competes with
57
+ * the page it is announcing something about.
58
+ *
59
+ * Uses the accent rather than a red or amber alert colour. This is a fact worth knowing, not a
60
+ * warning, and a venue putting "members book from Thursday" in an alarm colour reads as a problem
61
+ * rather than an offer.
62
+ */
63
+ declare function AnnouncementBar({ text, link }: {
64
+ text?: string;
65
+ link?: {
66
+ label: string;
67
+ href: string;
68
+ };
69
+ }): react_jsx_runtime.JSX.Element | null;
70
+ declare function HeroStatement({ headline, subheading, align }: {
71
+ headline: string;
72
+ subheading?: string;
73
+ align?: Align;
74
+ }): react_jsx_runtime.JSX.Element;
75
+ declare function AtomCard({ title, content, layout, stats, ctaText, ctaUrl, full }: {
76
+ title?: string;
77
+ content?: string;
78
+ layout?: string[];
79
+ stats?: {
80
+ value: string;
81
+ label: string;
82
+ }[];
83
+ ctaText?: string;
84
+ ctaUrl?: string;
85
+ full?: boolean;
86
+ }): react_jsx_runtime.JSX.Element;
87
+ declare function StatGrid({ stats, caption }: {
88
+ stats?: {
89
+ value: string;
90
+ label: string;
91
+ }[];
92
+ caption?: string;
93
+ }): react_jsx_runtime.JSX.Element;
94
+ declare function CodeBlock({ code, lang, caption }: {
95
+ code: string;
96
+ lang?: string;
97
+ caption?: string;
98
+ }): react_jsx_runtime.JSX.Element;
99
+ declare function ComparisonTable({ brand, competitor, rows }: {
100
+ brand?: string;
101
+ competitor: string;
102
+ rows?: {
103
+ label: string;
104
+ spectare: string;
105
+ them: string;
106
+ }[];
107
+ }): react_jsx_runtime.JSX.Element;
108
+ declare function FeatureList({ items }: {
109
+ items?: {
110
+ text: string;
111
+ sub?: string;
112
+ }[];
113
+ }): react_jsx_runtime.JSX.Element;
114
+ declare function TestimonialCard({ quote, name, role, company }: {
115
+ quote: string;
116
+ name: string;
117
+ role: string;
118
+ company?: string;
119
+ }): react_jsx_runtime.JSX.Element;
120
+ declare function ImageCtaHero({ src, alt, headline, subheading, ctaLabel, ctaHref, align }: {
121
+ src?: string;
122
+ alt?: string;
123
+ headline?: string;
124
+ subheading?: string;
125
+ ctaLabel?: string;
126
+ ctaHref?: string;
127
+ align?: Align;
128
+ }): react_jsx_runtime.JSX.Element | null;
129
+ declare function CtaStrip({ headline, primary, secondary, align }: {
130
+ headline: string;
131
+ primary: {
132
+ label: string;
133
+ href: string;
134
+ };
135
+ secondary?: {
136
+ label: string;
137
+ href: string;
138
+ };
139
+ align?: Align;
140
+ }): react_jsx_runtime.JSX.Element;
35
141
  interface ComponentRendererProps {
36
142
  assembly: ComponentAssembly;
37
143
  /** Your brand name, used as the left column header in ComparisonTable. Defaults to "Us". */
@@ -46,7 +152,55 @@ interface ComponentRendererProps {
46
152
  * default (HeroStatement left, CtaStrip centered).
47
153
  */
48
154
  align?: 'left' | 'center' | 'right';
155
+ /**
156
+ * Brand accent, as an explicit input rather than a custom property you override and hope
157
+ * wins on tag order. Any CSS colour. `--sp-accent-bg` and `--sp-accent-border` are derived
158
+ * from it, so one value covers all three. Defaults to #60a5fa.
159
+ */
160
+ accent?: string;
49
161
  }
50
- declare function ComponentRenderer({ assembly, brand, xray, zone, align }: ComponentRendererProps): react_jsx_runtime.JSX.Element;
162
+ declare function ComponentRenderer({ assembly, brand, xray, zone, align, accent }: ComponentRendererProps): react_jsx_runtime.JSX.Element;
163
+
164
+ /**
165
+ * The one place this package's design tokens are defined.
166
+ *
167
+ * Two things that are easy to get wrong here:
168
+ *
169
+ * 1. Tokens are declared on `.sp-root`, not `:root`. Declaring them at `:root` writes into
170
+ * the host's document, allows only one theme per page, and means a customer overriding
171
+ * `--sp-accent` competes with us at equal specificity so tag order decides the winner.
172
+ * Scoping also lets two differently themed sections sit on one page.
173
+ *
174
+ * 2. `tokens.css` must stay byte-identical to `buildTokenCss({ scope: STYLESHEET_SCOPE })`,
175
+ * which differs from the injected CSS in its selector and in nothing else.
176
+ * tests/unit/react-tokens.test.ts enforces both halves, because a customer who imports the
177
+ * stylesheet and a customer who renders the component must get the same page.
178
+ *
179
+ * components/ServerAssemblyRenderer.tsx in the Spectare app is a deliberate mirror of the
180
+ * values below. It is not the same file: that renderer uses rem, this one uses px.
181
+ */
182
+ /** The package default. `spectare.js` and the WordPress plugin default to #2563eb, which is
183
+ * a live inconsistency and a brand decision rather than a technical one, so it is left alone
184
+ * here. Pass `accent` to override on either path. */
185
+ declare const DEFAULT_ACCENT = "#60a5fa";
186
+ /** Class the tokens are scoped to. Applied by ComponentRenderer to its own wrapper. */
187
+ declare const SP_ROOT = "sp-root";
188
+ /**
189
+ * Returns the stylesheet a renderer injects.
190
+ *
191
+ * @param accent brand colour. Accepts anything CSS does; `--sp-accent-bg` and
192
+ * `--sp-accent-border` are derived from it so a customer sets one value
193
+ * rather than three. Defaults to DEFAULT_ACCENT.
194
+ * @param unit length unit for layout rules. 'px' for this package, 'rem' for the app's
195
+ * server renderer, which sizes in rem throughout.
196
+ * @param scope selector the custom properties are declared on. Defaults to `.sp-root`, which is
197
+ * what a renderer injecting this at runtime wants. `tokens.css` is generated with
198
+ * STYLESHEET_SCOPE instead, so importing it works without a wrapper.
199
+ */
200
+ declare function buildTokenCss({ accent, unit, scope }?: {
201
+ accent?: string;
202
+ unit?: 'px' | 'rem';
203
+ scope?: string;
204
+ }): string;
51
205
 
52
- export { type AssemblyZone, type ComponentAssembly, type ComponentName, ComponentRenderer, type ComponentRendererProps, PersonalisedSection, type PersonalisedSectionProps, filterAssemblyByZone };
206
+ export { AnnouncementBar, type AssemblyZone, AtomCard, CodeBlock, ComparisonTable, type ComponentAssembly, type ComponentName, ComponentRenderer, type ComponentRendererProps, CtaStrip, DEFAULT_ACCENT, FeatureList, HeroStatement, ImageCtaHero, PersonalisedSection, type PersonalisedSectionProps, SP_ROOT, StatGrid, TestimonialCard, buildTokenCss, filterAssemblyByZone };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
7
 
8
8
  // src/types.ts
9
9
  var COMPONENT_ZONES = {
10
+ AnnouncementBar: "announce",
10
11
  HeroStatement: "hero",
11
12
  ImageCtaHero: "hero",
12
13
  AtomCard: "body",
@@ -24,12 +25,83 @@ function filterAssemblyByZone(assembly, zone) {
24
25
  return effectiveZone === zone;
25
26
  });
26
27
  }
28
+
29
+ // src/tokens.ts
30
+ var DEFAULT_ACCENT = "#60a5fa";
31
+ var SP_ROOT = "sp-root";
32
+ function palette(accent) {
33
+ return {
34
+ "--sp-accent": accent,
35
+ "--sp-accent-bg": `color-mix(in srgb,${accent} 10%,transparent)`,
36
+ "--sp-accent-border": `color-mix(in srgb,${accent} 20%,transparent)`,
37
+ "--sp-bg": "#18181b",
38
+ "--sp-surface": "#09090b",
39
+ "--sp-border": "#27272a",
40
+ "--sp-text": "#f4f4f5",
41
+ "--sp-text-sub": "#d4d4d8",
42
+ "--sp-text-strong": "#e4e4e7",
43
+ "--sp-text-muted": "#82828b",
44
+ "--sp-text-dim": "#6b6b74",
45
+ "--sp-text-body": "#a1a1aa",
46
+ "--sp-positive": "#6ee7b7",
47
+ "--sp-cta-bg": "#fff",
48
+ "--sp-cta-text": "#09090b",
49
+ "--sp-divider": "rgba(255,255,255,0.06)",
50
+ "--sp-code-bg": "rgba(255,255,255,0.07)",
51
+ "--sp-code-border": "rgba(255,255,255,0.08)",
52
+ "--sp-pre-bg": "rgba(0,0,0,0.4)"
53
+ };
54
+ }
55
+ function rules(unit) {
56
+ const gap = unit === "px" ? "20px" : "1.25rem";
57
+ return [
58
+ ".sp-root .sp-prose p{margin-bottom:.625rem}",
59
+ ".sp-root .sp-prose p:last-child{margin-bottom:0}",
60
+ ".sp-root .sp-prose ul{list-style:disc;padding-left:1.25rem;margin-bottom:.625rem}",
61
+ ".sp-root .sp-prose ol{list-style:decimal;padding-left:1.25rem;margin-bottom:.625rem}",
62
+ ".sp-root .sp-prose li{margin-bottom:.25rem}",
63
+ ".sp-root .sp-prose strong{color:var(--sp-text-strong);font-weight:600}",
64
+ ".sp-root .sp-prose h2,.sp-root .sp-prose h3{color:var(--sp-text);font-weight:600;margin:.875rem 0 .375rem;font-size:1rem}",
65
+ ".sp-root .sp-prose code{background:var(--sp-code-bg);border:1px solid var(--sp-code-border);padding:.1rem .35rem;border-radius:.25rem;font-size:.82em;color:var(--sp-text)}",
66
+ ".sp-root .sp-prose blockquote{border-left:3px solid var(--sp-border);padding-left:1rem;color:var(--sp-text-muted);margin:.75rem 0}",
67
+ `.sp-root .sp-pair{display:grid;grid-template-columns:1fr 1fr;gap:${gap}}`,
68
+ "@media(max-width:640px){.sp-root .sp-pair{grid-template-columns:1fr}}"
69
+ ].join("");
70
+ }
71
+ function buildTokenCss({ accent = DEFAULT_ACCENT, unit = "px", scope = `.${SP_ROOT}` } = {}) {
72
+ const decls = Object.entries(palette(accent)).map(([k, v]) => `${k}:${v}`).join(";");
73
+ return `${scope}{${decls}}${rules(unit)}`;
74
+ }
27
75
  function renderMarkdown(content) {
28
76
  const raw = marked.parse(content != null ? content : "", { async: false });
29
77
  return DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
30
78
  }
31
79
  var HALF_COMPONENTS = /* @__PURE__ */ new Set(["TestimonialCard"]);
32
80
  var flexItems = (a) => a === "center" ? "center" : a === "right" ? "flex-end" : "flex-start";
81
+ function AnnouncementBar({ text, link }) {
82
+ if (!text) return null;
83
+ return /* @__PURE__ */ jsxs("div", { style: {
84
+ display: "flex",
85
+ alignItems: "center",
86
+ justifyContent: "center",
87
+ gap: "10px",
88
+ flexWrap: "wrap",
89
+ textAlign: "center",
90
+ background: "var(--sp-accent-bg)",
91
+ border: "1px solid var(--sp-accent-border)",
92
+ borderRadius: "8px",
93
+ padding: "8px 16px",
94
+ fontSize: "14px",
95
+ lineHeight: 1.4,
96
+ color: "var(--sp-text-sub)"
97
+ }, children: [
98
+ /* @__PURE__ */ jsx("span", { children: text }),
99
+ (link == null ? void 0 : link.href) && (link == null ? void 0 : link.label) && /* @__PURE__ */ jsxs("a", { href: link.href, style: { color: "var(--sp-accent)", fontWeight: 600, textDecoration: "none", whiteSpace: "nowrap" }, children: [
100
+ link.label,
101
+ " \u2192"
102
+ ] })
103
+ ] });
104
+ }
33
105
  function HeroStatement({ headline, subheading, align = "left" }) {
34
106
  return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", alignItems: flexItems(align), textAlign: align, paddingTop: "24px", paddingBottom: "24px" }, children: [
35
107
  /* @__PURE__ */ jsx("h2", { style: { fontSize: "32px", fontWeight: 700, letterSpacing: "-0.025em", lineHeight: 1.2, color: "var(--sp-text)", margin: "0 0 12px" }, children: headline }),
@@ -37,11 +109,18 @@ function HeroStatement({ headline, subheading, align = "left" }) {
37
109
  ] });
38
110
  }
39
111
  var ATOM_LAYOUT_DEFAULT = ["title", "content", "stats", "cta"];
40
- function AtomCard({ title, content, layout, stats = [], ctaText, ctaUrl }) {
112
+ var ATOM_CLAMP_LINES = 4;
113
+ var clampStyle = {
114
+ display: "-webkit-box",
115
+ WebkitLineClamp: ATOM_CLAMP_LINES,
116
+ WebkitBoxOrient: "vertical",
117
+ overflow: "hidden"
118
+ };
119
+ function AtomCard({ title, content, layout, stats = [], ctaText, ctaUrl, full }) {
41
120
  const order = layout && layout.length ? layout : ATOM_LAYOUT_DEFAULT;
42
121
  const element = (name) => {
43
122
  if (name === "title") return title ? /* @__PURE__ */ jsx("h3", { style: { fontSize: "16px", fontWeight: 600, color: "var(--sp-text)", margin: "0 0 12px" }, children: title }, "title") : null;
44
- if (name === "content") return content ? /* @__PURE__ */ jsx("div", { className: "sp-prose", style: { fontSize: "15px", color: "var(--sp-text-sub)", lineHeight: 1.6 }, dangerouslySetInnerHTML: { __html: renderMarkdown(content) } }, "content") : null;
123
+ if (name === "content") return content ? /* @__PURE__ */ jsx("div", { className: "sp-prose", style: { fontSize: "15px", color: "var(--sp-text-sub)", lineHeight: 1.6, ...full ? {} : clampStyle }, dangerouslySetInnerHTML: { __html: renderMarkdown(content) } }, "content") : null;
45
124
  if (name === "stats") return stats.length ? /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "20px", margin: "0 0 12px", paddingBottom: "12px", borderBottom: "1px solid var(--sp-border)" }, children: stats.map((s, i) => /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "2px" }, children: [
46
125
  /* @__PURE__ */ jsx("span", { style: { fontSize: "22px", fontWeight: 700, color: "var(--sp-text)", lineHeight: 1, fontVariantNumeric: "tabular-nums" }, children: s.value }),
47
126
  /* @__PURE__ */ jsx("span", { style: { fontSize: "11px", color: "var(--sp-text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" }, children: s.label })
@@ -129,7 +208,7 @@ function CtaStrip({ headline, primary, secondary, align = "center" }) {
129
208
  return /* @__PURE__ */ jsxs("div", { style: { border: "1px solid var(--sp-border)", borderRadius: "12px", padding: "32px", textAlign: align, background: "var(--sp-bg)" }, children: [
130
209
  /* @__PURE__ */ jsx("h3", { style: { fontSize: "24px", fontWeight: 700, color: "var(--sp-text)", margin: "0 0 24px" }, children: headline }),
131
210
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: flexItems(align), gap: "16px", flexWrap: "wrap" }, children: [
132
- /* @__PURE__ */ jsx("a", { href: primary.href, style: { background: "#fff", color: "#09090b", padding: "10px 24px", borderRadius: "12px", fontWeight: 600, fontSize: "15px", textDecoration: "none" }, children: primary.label }),
211
+ /* @__PURE__ */ jsx("a", { href: primary.href, style: { background: "var(--sp-cta-bg)", color: "var(--sp-cta-text)", padding: "10px 24px", borderRadius: "12px", fontWeight: 600, fontSize: "15px", textDecoration: "none" }, children: primary.label }),
133
212
  secondary && /* @__PURE__ */ jsxs("a", { href: secondary.href, style: { fontSize: "15px", color: "var(--sp-text-sub)", textDecoration: "none" }, children: [
134
213
  secondary.label,
135
214
  " \u2192"
@@ -141,10 +220,13 @@ function renderSingle(item, brand, align) {
141
220
  var _a, _b;
142
221
  const s = (_a = item.slots) != null ? _a : {};
143
222
  switch (item.component) {
223
+ case "AnnouncementBar":
224
+ return /* @__PURE__ */ jsx(AnnouncementBar, { text: s.text, link: s.link });
144
225
  case "HeroStatement":
145
226
  return /* @__PURE__ */ jsx(HeroStatement, { headline: s.headline, subheading: s.subheading, align });
227
+ // A full-width card has nothing beside it, so there is nothing for a clamp to protect.
146
228
  case "AtomCard":
147
- return /* @__PURE__ */ jsx(AtomCard, { title: s.title, content: s.content, layout: s.layout, stats: s.stats, ctaText: s.ctaText, ctaUrl: s.ctaUrl });
229
+ return /* @__PURE__ */ jsx(AtomCard, { title: s.title, content: s.content, layout: s.layout, stats: s.stats, ctaText: s.ctaText, ctaUrl: s.ctaUrl, full: s.full || resolvedSize(item) === "full" });
148
230
  case "StatGrid":
149
231
  return /* @__PURE__ */ jsx(StatGrid, { stats: s.stats, caption: s.caption });
150
232
  case "CodeBlock":
@@ -159,8 +241,20 @@ function renderSingle(item, brand, align) {
159
241
  return /* @__PURE__ */ jsx(ImageCtaHero, { src: s.src, alt: s.alt, headline: s.headline, subheading: s.subheading, ctaLabel: s.ctaLabel, ctaHref: s.ctaHref, align });
160
242
  case "CtaStrip":
161
243
  return /* @__PURE__ */ jsx(CtaStrip, { headline: s.headline, primary: s.primary, secondary: s.secondary, align });
162
- default:
163
- return null;
244
+ // An unrecognised component name used to render nothing at all. A component needs a
245
+ // renderer in three places plus the WordPress plugin, so missing one made its content
246
+ // silently absent on that surface only: no error, no warning, just a shorter page that
247
+ // looks deliberate. That is the failure mode to remove before the registry grows.
248
+ //
249
+ // Falls back to AtomCard when the slots carry something readable, since almost every
250
+ // component is built from an atom and title/content is the shape they share. When there
251
+ // is genuinely nothing to show it still renders nothing, because an empty card is worse
252
+ // than a gap, but the warning fires either way.
253
+ default: {
254
+ console.warn(`[spectare] ComponentRenderer: no renderer for "${item.component}", falling back to AtomCard`);
255
+ if (!s.title && !s.content) return null;
256
+ return /* @__PURE__ */ jsx(AtomCard, { title: s.title, content: s.content, stats: s.stats, ctaText: s.ctaText, ctaUrl: s.ctaUrl, full: true });
257
+ }
164
258
  }
165
259
  }
166
260
  function resolvedSize(item) {
@@ -192,12 +286,11 @@ function XrayBadge({ name, size }) {
192
286
  /* @__PURE__ */ jsx("span", { style: { fontSize: "10px", fontFamily: "monospace", color: "#71717a", lineHeight: 1 }, children: size })
193
287
  ] });
194
288
  }
195
- var PAIR_STYLE = ".sp-pair{display:grid;grid-template-columns:1fr 1fr;gap:20px}@media(max-width:640px){.sp-pair{grid-template-columns:1fr}}";
196
- function ComponentRenderer({ assembly, brand, xray = false, zone, align }) {
289
+ function ComponentRenderer({ assembly, brand, xray = false, zone, align, accent }) {
197
290
  const filtered = zone ? filterAssemblyByZone(assembly, zone) : assembly;
198
291
  const layout = buildLayout(filtered);
199
- return /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "20px", fontSize: "16px" }, children: [
200
- /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: PAIR_STYLE } }),
292
+ return /* @__PURE__ */ jsxs("div", { className: SP_ROOT, style: { display: "flex", flexDirection: "column", gap: "20px", fontSize: "16px" }, children: [
293
+ /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: buildTokenCss({ accent }) } }),
201
294
  layout.map((group, i) => {
202
295
  if (group.type === "full") {
203
296
  return /* @__PURE__ */ jsxs("div", { style: xray ? { position: "relative" } : void 0, children: [
@@ -224,7 +317,20 @@ function ComponentRenderer({ assembly, brand, xray = false, zone, align }) {
224
317
  })
225
318
  ] });
226
319
  }
227
- var CSS_TOKENS = `:root{--sp-accent:#60a5fa;--sp-accent-bg:rgba(96,165,250,0.1);--sp-accent-border:rgba(96,165,250,0.2);--sp-bg:#18181b;--sp-surface:#09090b;--sp-border:#27272a;--sp-text:#f4f4f5;--sp-text-sub:#d4d4d8;--sp-text-strong:#e4e4e7;--sp-text-muted:#71717a;--sp-text-dim:#52525b;--sp-text-body:#a1a1aa;--sp-positive:#6ee7b7;--sp-divider:rgba(255,255,255,0.06);--sp-code-bg:rgba(255,255,255,0.07);--sp-code-border:rgba(255,255,255,0.08);--sp-pre-bg:rgba(0,0,0,0.4)}.sp-prose p{margin-bottom:.625rem}.sp-prose p:last-child{margin-bottom:0}.sp-prose ul{list-style:disc;padding-left:1.25rem;margin-bottom:.625rem}.sp-prose ol{list-style:decimal;padding-left:1.25rem;margin-bottom:.625rem}.sp-prose li{margin-bottom:.25rem}.sp-prose strong{color:var(--sp-text-strong);font-weight:600}.sp-prose h2,.sp-prose h3{color:var(--sp-text);font-weight:600;margin:.875rem 0 .375rem;font-size:1rem}.sp-prose code{background:var(--sp-code-bg);border:1px solid var(--sp-code-border);padding:.1rem .35rem;border-radius:.25rem;font-size:.82em;color:var(--sp-text)}.sp-prose blockquote{border-left:3px solid var(--sp-border);padding-left:1rem;color:var(--sp-text-muted);margin:.75rem 0}`;
320
+ var VARIANT_KEY = (orgSlug) => `_spectare_variant_${orgSlug}`;
321
+ function readVariantId(orgSlug) {
322
+ try {
323
+ return sessionStorage.getItem(VARIANT_KEY(orgSlug));
324
+ } catch (e) {
325
+ return null;
326
+ }
327
+ }
328
+ function writeVariantId(orgSlug, variantId) {
329
+ try {
330
+ sessionStorage.setItem(VARIANT_KEY(orgSlug), variantId);
331
+ } catch (e) {
332
+ }
333
+ }
228
334
  function buildSummary(pageHint, orgSlug) {
229
335
  const parts = [pageHint];
230
336
  const referrer = document.referrer;
@@ -276,32 +382,39 @@ function PersonalisedSection({
276
382
  style,
277
383
  initialAssembly,
278
384
  initialIntent,
385
+ initialVariantId,
386
+ directSummary,
279
387
  zone,
280
- align
388
+ align,
389
+ accent
281
390
  }) {
282
391
  const [assembly, setAssembly] = useState(initialAssembly != null ? initialAssembly : []);
283
392
  const [loading, setLoading] = useState(!initialAssembly);
284
393
  const [orgName, setOrgName] = useState(void 0);
394
+ const [debugMode, setDebugMode] = useState(false);
395
+ const [debugIntent, setDebugIntent] = useState(null);
285
396
  const tokenRef = useRef(null);
286
397
  const conversionFiredRef = useRef(false);
398
+ useEffect(() => {
399
+ setDebugMode(new URLSearchParams(window.location.search).has("spectare_debug"));
400
+ if (initialIntent) setDebugIntent(initialIntent);
401
+ }, [initialIntent]);
402
+ useEffect(() => {
403
+ if (initialVariantId) writeVariantId(orgSlug, initialVariantId);
404
+ }, [orgSlug, initialVariantId]);
287
405
  useEffect(() => {
288
406
  let cancelled = false;
289
407
  async function load() {
290
- var _a, _b, _c, _d, _e, _f, _g, _h;
408
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
291
409
  try {
292
- const p = new URLSearchParams(window.location.search);
293
- const utmSuffix = ["utm_source", "utm_medium", "utm_campaign", "utm_term"].map((k) => {
294
- var _a2;
295
- return (_a2 = p.get(k)) != null ? _a2 : "";
296
- }).filter(Boolean).join("_");
297
- const tokenKey = `_spectare_token_${orgSlug}${utmSuffix ? "_" + utmSuffix : ""}`;
298
- let token = sessionStorage.getItem(tokenKey);
299
- if (!token) {
300
- const summary = buildSummary(pageHint != null ? pageHint : `Visiting ${document.title}`, orgSlug);
410
+ let token = null;
411
+ if (directSummary) {
412
+ setLoading(true);
413
+ setAssembly([]);
301
414
  const res2 = await fetch(`${baseUrl}/api/qualify/${orgSlug}`, {
302
415
  method: "POST",
303
416
  headers: { "Content-Type": "application/json" },
304
- body: JSON.stringify({ summary })
417
+ body: JSON.stringify({ summary: directSummary, direct: true })
305
418
  });
306
419
  if (!res2.ok || cancelled) {
307
420
  setLoading(false);
@@ -309,8 +422,30 @@ function PersonalisedSection({
309
422
  }
310
423
  const data = await res2.json();
311
424
  token = (_a = data.token) != null ? _a : null;
312
- if (token) sessionStorage.setItem(tokenKey, token);
313
- if (data.orgName) setOrgName(data.orgName);
425
+ } else {
426
+ const p = new URLSearchParams(window.location.search);
427
+ const utmSuffix = ["utm_source", "utm_medium", "utm_campaign", "utm_term"].map((k) => {
428
+ var _a2;
429
+ return (_a2 = p.get(k)) != null ? _a2 : "";
430
+ }).filter(Boolean).join("_");
431
+ const tokenKey = `_spectare_token_${orgSlug}${utmSuffix ? "_" + utmSuffix : ""}`;
432
+ token = sessionStorage.getItem(tokenKey);
433
+ if (!token) {
434
+ const summary = buildSummary(pageHint != null ? pageHint : `Visiting ${document.title}`, orgSlug);
435
+ const res2 = await fetch(`${baseUrl}/api/qualify/${orgSlug}`, {
436
+ method: "POST",
437
+ headers: { "Content-Type": "application/json" },
438
+ body: JSON.stringify({ summary })
439
+ });
440
+ if (!res2.ok || cancelled) {
441
+ setLoading(false);
442
+ return;
443
+ }
444
+ const data = await res2.json();
445
+ token = (_b = data.token) != null ? _b : null;
446
+ if (token) sessionStorage.setItem(tokenKey, token);
447
+ if (data.orgName) setOrgName(data.orgName);
448
+ }
314
449
  }
315
450
  if (!token || cancelled) {
316
451
  setLoading(false);
@@ -320,9 +455,19 @@ function PersonalisedSection({
320
455
  let res = await fetch(`${baseUrl}/api/assemble/components`, {
321
456
  method: "POST",
322
457
  headers: { "Content-Type": "application/json" },
323
- body: JSON.stringify({ token, orgSlug })
458
+ // Direct: flag it so the server cost-caps the build, and omit the seen-variant
459
+ // exclusion so it can read the shared cache instead of forcing a fresh build.
460
+ body: JSON.stringify(
461
+ directSummary ? { token, orgSlug, direct: true } : { token, orgSlug, excludeVariantId: readVariantId(orgSlug) }
462
+ )
324
463
  });
325
- if (res.status === 401 && !cancelled) {
464
+ if (res.status === 401 && !cancelled && !directSummary) {
465
+ const p = new URLSearchParams(window.location.search);
466
+ const utmSuffix = ["utm_source", "utm_medium", "utm_campaign", "utm_term"].map((k) => {
467
+ var _a2;
468
+ return (_a2 = p.get(k)) != null ? _a2 : "";
469
+ }).filter(Boolean).join("_");
470
+ const tokenKey = `_spectare_token_${orgSlug}${utmSuffix ? "_" + utmSuffix : ""}`;
326
471
  sessionStorage.removeItem(tokenKey);
327
472
  const summary = buildSummary(pageHint != null ? pageHint : `Visiting ${document.title}`, orgSlug);
328
473
  const qRes = await fetch(`${baseUrl}/api/qualify/${orgSlug}`, {
@@ -335,7 +480,7 @@ function PersonalisedSection({
335
480
  return;
336
481
  }
337
482
  const qData = await qRes.json();
338
- token = (_b = qData.token) != null ? _b : null;
483
+ token = (_c = qData.token) != null ? _c : null;
339
484
  if (token) {
340
485
  sessionStorage.setItem(tokenKey, token);
341
486
  tokenRef.current = token;
@@ -347,14 +492,14 @@ function PersonalisedSection({
347
492
  res = await fetch(`${baseUrl}/api/assemble/components`, {
348
493
  method: "POST",
349
494
  headers: { "Content-Type": "application/json" },
350
- body: JSON.stringify({ token, orgSlug })
495
+ body: JSON.stringify({ token, orgSlug, excludeVariantId: readVariantId(orgSlug) })
351
496
  });
352
497
  }
353
498
  if (!res.ok || cancelled) {
354
499
  setLoading(false);
355
500
  return;
356
501
  }
357
- const contentType = (_c = res.headers.get("content-type")) != null ? _c : "";
502
+ const contentType = (_d = res.headers.get("content-type")) != null ? _d : "";
358
503
  if (contentType.includes("text/event-stream")) {
359
504
  const reader = res.body.getReader();
360
505
  const decoder = new TextDecoder();
@@ -366,13 +511,26 @@ function PersonalisedSection({
366
511
  if (done || cancelled) break;
367
512
  buf += decoder.decode(value, { stream: true });
368
513
  const lines = buf.split("\n");
369
- buf = (_d = lines.pop()) != null ? _d : "";
514
+ buf = (_e = lines.pop()) != null ? _e : "";
370
515
  for (const line of lines) {
371
516
  if (!line.startsWith("data: ")) continue;
372
517
  const payload = line.slice(6).trim();
373
518
  if (payload === "[DONE]") break;
374
519
  try {
375
- const comp = JSON.parse(payload);
520
+ const parsed = JSON.parse(payload);
521
+ if (parsed._variant && typeof parsed._variant === "string") {
522
+ writeVariantId(orgSlug, parsed._variant);
523
+ continue;
524
+ }
525
+ if (parsed._error) {
526
+ if (!loadingCleared) {
527
+ setLoading(false);
528
+ loadingCleared = true;
529
+ }
530
+ continue;
531
+ }
532
+ if (!parsed.component) continue;
533
+ const comp = parsed;
376
534
  if (!loadingCleared) {
377
535
  setLoading(false);
378
536
  loadingCleared = true;
@@ -388,10 +546,14 @@ function PersonalisedSection({
388
546
  }
389
547
  } else {
390
548
  const data = await res.json();
549
+ if (data.variantId) writeVariantId(orgSlug, data.variantId);
391
550
  if (cancelled) return;
392
- const clientAudience = (_f = (_e = data.intent) == null ? void 0 : _e.userContext) == null ? void 0 : _f.audience;
393
- const clientStage = (_h = (_g = data.intent) == null ? void 0 : _g.userContext) == null ? void 0 : _h.stage;
551
+ const clientAudience = (_g = (_f = data.intent) == null ? void 0 : _f.userContext) == null ? void 0 : _g.audience;
552
+ const clientStage = (_i = (_h = data.intent) == null ? void 0 : _h.userContext) == null ? void 0 : _i.stage;
394
553
  const ssrMatches = initialIntent && clientAudience === initialIntent.audience && clientStage === initialIntent.stage;
554
+ if (clientAudience && clientStage) {
555
+ setDebugIntent({ audience: clientAudience, stage: clientStage });
556
+ }
395
557
  if (!ssrMatches && Array.isArray(data.assembly) && data.assembly.length > 0) {
396
558
  setAssembly(data.assembly);
397
559
  }
@@ -405,7 +567,7 @@ function PersonalisedSection({
405
567
  return () => {
406
568
  cancelled = true;
407
569
  };
408
- }, [orgSlug, baseUrl, pageHint, initialAssembly, initialIntent]);
570
+ }, [orgSlug, baseUrl, pageHint, initialAssembly, initialIntent, directSummary]);
409
571
  useEffect(() => {
410
572
  if (!assembly.length || conversionFiredRef.current) return;
411
573
  const token = tokenRef.current;
@@ -450,6 +612,7 @@ function PersonalisedSection({
450
612
  token,
451
613
  orgSlug,
452
614
  url,
615
+ variantId: readVariantId(orgSlug),
453
616
  ...atomsShown.length ? { atomsShown } : {},
454
617
  ...visitPath.length > 1 ? { visitPath } : {},
455
618
  ...data.goalName ? { goalName: data.goalName } : {},
@@ -461,13 +624,24 @@ function PersonalisedSection({
461
624
  });
462
625
  }, [assembly, orgSlug, baseUrl]);
463
626
  if (!loading && assembly.length === 0) return null;
627
+ const debugStyle = debugMode ? { outline: "2px solid rgba(96,165,250,0.5)", borderRadius: "0.5rem", position: "relative", padding: "0.5rem", ...style } : style;
464
628
  return /* @__PURE__ */ jsxs(Fragment, { children: [
465
- /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: CSS_TOKENS } }),
466
629
  /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: "@keyframes sp-pulse{0%,100%{opacity:1}50%{opacity:0.4}}" } }),
467
- /* @__PURE__ */ jsx("div", { className, style, children: loading && assembly.length === 0 ? /* @__PURE__ */ jsx(Shimmer, {}) : /* @__PURE__ */ jsx(ComponentRenderer, { assembly: zone ? filterAssemblyByZone(assembly, zone) : assembly, brand: brand != null ? brand : orgName, align }) })
630
+ /* @__PURE__ */ jsxs("div", { className, style: debugStyle, children: [
631
+ debugMode && /* @__PURE__ */ jsxs("div", { style: { position: "absolute", top: "-1px", right: "-1px", zIndex: 20, display: "flex", alignItems: "center", gap: "0.375rem", background: "rgba(96,165,250,0.9)", borderRadius: "0 0.5rem 0 0.375rem", padding: "0.25rem 0.625rem", fontSize: "0.625rem", fontFamily: "monospace", color: "#fff", pointerEvents: "none", userSelect: "none" }, children: [
632
+ "Personalised by Spectare",
633
+ debugIntent && /* @__PURE__ */ jsxs("span", { style: { opacity: 0.75 }, children: [
634
+ " ",
635
+ debugIntent.audience,
636
+ "/",
637
+ debugIntent.stage
638
+ ] })
639
+ ] }),
640
+ loading && assembly.length === 0 ? /* @__PURE__ */ jsx(Shimmer, {}) : /* @__PURE__ */ jsx(ComponentRenderer, { assembly: zone ? filterAssemblyByZone(assembly, zone) : assembly, brand: brand != null ? brand : orgName, align, accent })
641
+ ] })
468
642
  ] });
469
643
  }
470
644
 
471
- export { ComponentRenderer, PersonalisedSection, filterAssemblyByZone };
645
+ export { AnnouncementBar, AtomCard, CodeBlock, ComparisonTable, ComponentRenderer, CtaStrip, DEFAULT_ACCENT, FeatureList, HeroStatement, ImageCtaHero, PersonalisedSection, SP_ROOT, StatGrid, TestimonialCard, buildTokenCss, filterAssemblyByZone };
472
646
  //# sourceMappingURL=index.js.map
473
647
  //# sourceMappingURL=index.js.map