@web-my-money/studio-consumer 2.1.0 → 2.3.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.
@@ -0,0 +1,148 @@
1
+ // @ts-check
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import * as t from "./templates.mjs";
5
+
6
+ /**
7
+ * The mechanical edits `init` makes (stage 2 plan, Task 4). Every writer is
8
+ * idempotent and never overwrites a file the app already has with different
9
+ * content: it reports it instead, so nothing the developer wrote is lost. The
10
+ * edits that need judgement (root layout, proxy, the content model) are left to
11
+ * the Claude Code step.
12
+ */
13
+
14
+ /**
15
+ * @typedef {{ siteKey: string, theme: string, style: "flat" | "glass" }} SiteValues
16
+ * @typedef {{ written: string[], skipped: { path: string, reason: string }[] }} WriteReport
17
+ */
18
+
19
+ /** @param {string} cwd */
20
+ function codeRoot(cwd) {
21
+ return existsSync(path.join(cwd, "src", "app")) ? "src" : ".";
22
+ }
23
+
24
+ /** @param {string} cwd */
25
+ function hasTestRunner(cwd) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8"));
28
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
29
+ return "vitest" in all || "jest" in all;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {{ cwd: string, values: SiteValues, modules: string[] }} ctx
37
+ * @returns {Promise<WriteReport>}
38
+ */
39
+ export async function writeSiteFiles({ cwd, values, modules }) {
40
+ const root = codeRoot(cwd);
41
+ const content = modules.includes("content");
42
+ const analytics = modules.includes("analytics");
43
+ // From app/api/<group>/<name>/route.ts back up to the code root's lib/.
44
+ const toLib = "../../../../lib";
45
+
46
+ /** @type {[string, string, { keepExisting?: boolean }?][]} */
47
+ const files = [];
48
+ const at = (/** @type {string} */ p) => (root === "." ? p : `${root}/${p}`);
49
+ if (content || analytics) files.push([at("lib/studio.ts"), t.studioTs(values)]);
50
+ if (content) {
51
+ files.push([at("lib/brand.ts"), t.brandTs(values)]);
52
+ files.push([at("lib/content-manifest.ts"), t.contentManifestTs(), { keepExisting: true }]);
53
+ files.push([at("lib/content.ts"), t.contentTs()]);
54
+ files.push([at("app/api/content/manifest/route.ts"), t.manifestRouteTs(toLib)]);
55
+ files.push([at("app/api/content/revalidate/route.ts"), t.revalidateRouteTs()]);
56
+ }
57
+ if (analytics) files.push([at("app/api/analytics/collect/route.ts"), t.collectRouteTs(toLib, content)]);
58
+
59
+ /** @type {WriteReport} */
60
+ const report = { written: [], skipped: [] };
61
+
62
+ if (content) {
63
+ if (hasTestRunner(cwd)) files.push(["tests/studio-frame-ancestors.test.ts", t.frameAncestorsTestTs()]);
64
+ else {
65
+ report.skipped.push({
66
+ path: "tests/studio-frame-ancestors.test.ts",
67
+ reason: "this app has no test runner yet; the Claude Code step can add Vitest and this test",
68
+ });
69
+ }
70
+ }
71
+
72
+ for (const [rel, body, opts] of files) {
73
+ const abs = path.join(cwd, rel);
74
+ if (existsSync(abs)) {
75
+ const current = readFileSync(abs, "utf8");
76
+ if (current === body || opts?.keepExisting) continue;
77
+ report.skipped.push({ path: rel, reason: "already exists with different content; merge the Studio version by hand" });
78
+ continue;
79
+ }
80
+ mkdirSync(path.dirname(abs), { recursive: true });
81
+ writeFileSync(abs, body);
82
+ report.written.push(rel);
83
+ }
84
+ return report;
85
+ }
86
+
87
+ const IMPORT_LINE = 'import { withStudio } from "@web-my-money/studio-consumer/next";';
88
+ const CONFIG_NAMES = ["next.config.ts", "next.config.mjs", "next.config.js"];
89
+
90
+ /**
91
+ * Wrap the app's next.config with withStudio. Recognises the two shapes nearly
92
+ * every config has (`export default name;` and `export default { … };`); any
93
+ * other shape is left untouched and the exact edit is returned instead.
94
+ *
95
+ * @param {{ cwd: string }} ctx
96
+ * @returns {Promise<"wrapped" | "already" | { manual: string }>}
97
+ */
98
+ export async function wrapNextConfig({ cwd }) {
99
+ const name = CONFIG_NAMES.find((n) => existsSync(path.join(cwd, n)));
100
+ if (!name) {
101
+ writeFileSync(path.join(cwd, "next.config.mjs"), `${IMPORT_LINE}\n\nexport default withStudio({});\n`);
102
+ return "wrapped";
103
+ }
104
+ const file = path.join(cwd, name);
105
+ const src = readFileSync(file, "utf8");
106
+ if (src.includes("withStudio(")) return "already";
107
+
108
+ const manual = {
109
+ manual:
110
+ `Edit ${name} by hand: add\n ${IMPORT_LINE}\nat the top, and wrap the exported config: export default withStudio(yourConfig);`,
111
+ };
112
+
113
+ let out = null;
114
+ const named = /^export default ([A-Za-z_$][\w$]*);[ \t]*$/m;
115
+ if (named.test(src)) {
116
+ out = src.replace(named, "export default withStudio($1);");
117
+ } else if (/^export default \{/m.test(src)) {
118
+ const start = src.search(/^export default \{/m);
119
+ const open = start + "export default ".length;
120
+ // Find the brace that closes the exported object. Only wrap when that object
121
+ // is the last thing in the file: a brace inside a string, or code after the
122
+ // export, makes the match uncertain, and a wrong splice breaks the config.
123
+ let depth = 0;
124
+ let close = -1;
125
+ for (let i = open; i < src.length; i++) {
126
+ if (src[i] === "{") depth++;
127
+ else if (src[i] === "}" && --depth === 0) {
128
+ close = i;
129
+ break;
130
+ }
131
+ }
132
+ const rest = close === -1 ? "x" : src.slice(close + 1).replace(/^;/, "");
133
+ if (close !== -1 && rest.trim() === "") {
134
+ out = `${src.slice(0, start)}export default withStudio(${src.slice(open, close + 1)});${rest}`;
135
+ }
136
+ }
137
+ if (!out) return manual;
138
+
139
+ // The import goes after the last existing import, or at the very top.
140
+ const lines = out.split("\n");
141
+ let last = -1;
142
+ lines.forEach((l, i) => {
143
+ if (/^import\s/.test(l)) last = i;
144
+ });
145
+ lines.splice(last + 1, 0, IMPORT_LINE);
146
+ writeFileSync(file, lines.join("\n"));
147
+ return "wrapped";
148
+ }
package/package.json CHANGED
@@ -1,29 +1,33 @@
1
- {
2
- "name": "@web-my-money/studio-consumer",
3
- "version": "2.1.0",
4
- "description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
5
- "license": "UNLICENSED",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/Web-My-Money/wmm-studio.git",
9
- "directory": "packages/studio-consumer"
10
- },
11
- "type": "module",
12
- "sideEffects": false,
13
- "publishConfig": {
14
- "access": "public"
15
- },
16
- "exports": {
17
- "./analytics": "./src/analytics/index.ts",
18
- "./content": "./src/content/index.ts",
19
- "./attribution": "./src/attribution/index.ts",
20
- "./headers": "./src/content/headers.mjs",
21
- "./brand": "./src/brand/index.ts",
22
- "./image": "./src/image/index.ts"
23
- },
24
- "peerDependencies": {
25
- "next": ">=16",
26
- "react": ">=19"
27
- },
28
- "files": ["src"]
29
- }
1
+ {
2
+ "name": "@web-my-money/studio-consumer",
3
+ "version": "2.3.0",
4
+ "description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Web-My-Money/wmm-studio.git",
9
+ "directory": "packages/studio-consumer"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ "./analytics": "./src/analytics/index.ts",
18
+ "./content": "./src/content/index.ts",
19
+ "./attribution": "./src/attribution/index.ts",
20
+ "./headers": "./src/content/headers.mjs",
21
+ "./brand": "./src/brand/index.ts",
22
+ "./image": "./src/image/index.ts",
23
+ "./next": "./src/next/index.mjs"
24
+ },
25
+ "peerDependencies": {
26
+ "next": ">=16",
27
+ "react": ">=19"
28
+ },
29
+ "bin": {
30
+ "studio-consumer": "./bin/studio-consumer.mjs"
31
+ },
32
+ "files": ["src", "bin", "cli", "skills"]
33
+ }
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: onboard-site
3
+ description: Finish connecting this Next.js app to WMM Studio after `npx @web-my-money/studio-consumer init` has run. Wires the root layout and the proxy/middleware, then proposes which text should be editable in Studio and writes the content manifest once the developer approves. Use when the init command opens Claude Code on /onboard-site.
4
+ ---
5
+
6
+ # Finish connecting this app to WMM Studio
7
+
8
+ The `init` command already did the mechanical part: it installed
9
+ `@web-my-money/studio-consumer`, wrapped `next.config` with `withStudio`, and wrote
10
+ `lib/studio.ts` (the site key), `lib/brand.ts`, `lib/content.ts`, a starter
11
+ `lib/content-manifest.ts` and the API routes under `app/api/`. (In a `src/` app all of
12
+ these live under `src/`.) Your job is the part that needs judgement on someone else's
13
+ code.
14
+
15
+ **The developer may never have seen Studio before.** Explain each change in one plain
16
+ sentence, show the diff, and wait for a yes before writing it. Never rewrite a file
17
+ wholesale; make the smallest edit that does the job. Never remove an existing redirect,
18
+ rewrite, header or script.
19
+
20
+ Read `.wmm-onboarding/state.json` first: it lists the modules this site uses
21
+ (`content`, `analytics`, `forms`, `attribution`, `ab`, …). Skip anything for a module
22
+ that is not listed.
23
+
24
+ ## 1. Root layout (`app/layout.tsx`, or the layout that renders `<html>`)
25
+
26
+ - **Brand (content):** `import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";`
27
+ and `import { brand } from "<relative path>/lib/brand";`, then spread
28
+ `{...brandRootAttributes(brand)}` on `<html>`. Keep every attribute already there.
29
+ - **Click-to-edit (content):** render `<WmmEditOverlay studioOrigin={process.env.NEXT_PUBLIC_STUDIO_ORIGIN} />`
30
+ from `@web-my-money/studio-consumer/content` once, inside `<body>`.
31
+ - **Analytics (analytics):** render `<FunnelAnalytics />` and `<EngagementTracking />` from
32
+ `@web-my-money/studio-consumer/analytics` once, inside `<body>`.
33
+ - **Overrides (content):** wherever the app loads its dictionary for a locale (often a
34
+ `[lang]` layout or a `getDictionary` helper), pass it through
35
+ `await applyDictOverrides(dict, locale)` from `lib/content.ts` before rendering. This is
36
+ what makes an edit in Studio appear on the page; without it the checklist goes green
37
+ and edits never show. If the app has no dictionary, say so and use `getLocalizedSlot`
38
+ for each editable value in step 3 instead.
39
+
40
+ ## 2. Proxy / middleware (analytics)
41
+
42
+ Studio's analytics and A/B testing need the `wmm_vid` visitor cookie, which
43
+ `withVisitorCookie` from `@web-my-money/studio-consumer/analytics` sets.
44
+
45
+ - Next 16 uses `proxy.ts` (older apps: `middleware.ts`), at the code root.
46
+ - **None exists:** create `proxy.ts` that returns `withVisitorCookie(request)`, with a
47
+ matcher that skips `_next`, `api` and static files.
48
+ - **One exists:** compose, never replace. Call `withVisitorCookie(request)` for the
49
+ pass-through case, and keep every existing redirect and rewrite exactly as it is. If the
50
+ existing code returns its own `NextResponse`, show the developer the two options (set
51
+ the cookie on that response, or call `withVisitorCookie` first) and let them choose.
52
+
53
+ ## 3. Choose the editable text (content)
54
+
55
+ This is the real decision in the whole onboarding. **Do not decide it alone.**
56
+
57
+ 1. Find the copy: the per-locale dictionary (e.g. `dictionaries/en.json`, `es.json`), or,
58
+ if there is none, the text in the page components.
59
+ 2. Propose a table grouped by page and section, one row per candidate:
60
+
61
+ | Key | Current text (EN) | Editable? | Why |
62
+ |---|---|---|---|
63
+
64
+ Keys are `page.section.thing` (e.g. `home.hero.headline`). Give a one-line reason for
65
+ **every "no"**. Lean towards "no" for, and always justify:
66
+ - legal and compliance text (privacy, terms, disclaimers, medical or financial claims);
67
+ - form field names, validation and error messages;
68
+ - analytics, tracking and pixel ids;
69
+ - icons and component references (never editable);
70
+ - anything the code uses as an identifier, a route or a CSS class.
71
+ Lean towards "yes" for headlines, sub-headlines, body copy, calls to action, testimonials
72
+ and images a marketer would change.
73
+ 3. Wait for the developer to approve or edit the table.
74
+ 4. Then fill in `lib/content-manifest.ts`, keeping its `getManifest()` shape and the
75
+ `siteKey: SITE_KEY` it returns:
76
+ - a slot only for text the code actually renders;
77
+ - `type`: `text` (or `richtext`, `image`, `list`, `select` where that fits);
78
+ - `dictPath` for dictionary text (the override is merged into the dictionary, no
79
+ component changes needed); no `dictPath` for images or other non-dictionary values,
80
+ which are read with `getSlot` and passed down as props;
81
+ - `default` is read from the dictionary (`en`/`es` imports), **never retyped**, so it
82
+ cannot go stale;
83
+ - `label` in plain words a client understands ("Home: hero headline");
84
+ - `group` per page section, `sortOrder` in reading order.
85
+
86
+ If `lib/content-manifest.ts` already had real slots before `init` ran, keep them and only
87
+ add what the developer approves.
88
+
89
+ ## 4. Check, then hand back
90
+
91
+ Run `npm run verify` (or the app's equivalent: typecheck, lint, test, build). Fix what it
92
+ reports. If the app has no test runner and `tests/studio-frame-ancestors.test.ts` was not
93
+ written, offer to add Vitest and that test.
94
+
95
+ When everything passes, tell the developer in one sentence what changed, and that they can
96
+ close Claude Code (type `/exit`) so the `init` command continues with the next step.
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { resolveVariant, VISITOR_COOKIE, type RunningExperiment } from "./bucketing";
2
3
 
3
4
  /**
4
5
  * First-party analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.1).
@@ -13,6 +14,9 @@ import { NextResponse } from "next/server";
13
14
  * surface an error on a marketing page or interfere with a form — so failures
14
15
  * are logged here and are invisible to the visitor.
15
16
  *
17
+ * Same-origin only, and the A/B arm is resolved HERE from the visitor cookie,
18
+ * never taken from the payload (parity with WMM Website's own route, 2026-10-09).
19
+ *
16
20
  * Inert with no `FUNNEL_INGEST_URL`: nothing is forwarded and the site behaves
17
21
  * exactly as it did before this route existed. That is the correct state before
18
22
  * rollout, which is what lets this merge ahead of being switched on.
@@ -61,6 +65,34 @@ function warnOnceAboutKeyShape(key: string): void {
61
65
  );
62
66
  }
63
67
 
68
+ /**
69
+ * Only the site's own pages post here. Browsers send `Sec-Fetch-Site` (or at least
70
+ * an `Origin`) on a same-origin beacon; a bare script or a third-party page does
71
+ * not. Headers can be forged by hand, so this is a speed bump, not a lock; what
72
+ * actually protects the A/B numbers is the server-side arm below.
73
+ */
74
+ function isSameOrigin(req: Request): boolean {
75
+ const fetchSite = req.headers.get("sec-fetch-site");
76
+ if (fetchSite) return fetchSite === "same-origin";
77
+ const origin = req.headers.get("origin");
78
+ if (!origin) return false;
79
+ try {
80
+ return new URL(origin).host === new URL(req.url).host;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function readCookie(req: Request, name: string): string | null {
87
+ const header = req.headers.get("cookie");
88
+ if (!header) return null;
89
+ for (const part of header.split(";")) {
90
+ const [k, ...v] = part.trim().split("=");
91
+ if (k === name) return decodeURIComponent(v.join("="));
92
+ }
93
+ return null;
94
+ }
95
+
64
96
  /** Test-only: forget that the one-time warning has already been printed. */
65
97
  export function resetIngestKeyShapeWarning(): void {
66
98
  warnedAboutKeyShape = false;
@@ -70,6 +102,13 @@ export function createCollectHandler(config: {
70
102
  siteKey: string;
71
103
  ingestUrl: string;
72
104
  ingestKey: string;
105
+ /**
106
+ * The site's running experiments (pass the content client's
107
+ * `getRunningExperiments`). With it, each event's A/B arm is resolved here
108
+ * from the `wmm_vid` cookie; without it, events carry no arm. The browser's
109
+ * own `variant` is discarded either way.
110
+ */
111
+ getRunningExperiments?: () => Promise<RunningExperiment[]>;
73
112
  }): (request: Request) => Promise<Response> {
74
113
  return async function POST(request: Request): Promise<Response> {
75
114
  const url = config.ingestUrl || DEFAULT_INGEST_URL;
@@ -77,6 +116,7 @@ export function createCollectHandler(config: {
77
116
  // The token is the only thing that genuinely has to be configured per site.
78
117
  if (!token) return noContent();
79
118
  warnOnceAboutKeyShape(token);
119
+ if (!isSameOrigin(request)) return noContent();
80
120
 
81
121
  let events: unknown[];
82
122
  try {
@@ -93,13 +133,39 @@ export function createCollectHandler(config: {
93
133
  return noContent();
94
134
  }
95
135
 
136
+ // The A/B arm is decided here, from the visitor cookie and the experiment list
137
+ // the page render used — never taken from the payload. A posted `variant` could
138
+ // otherwise put any visitor into any arm and move an experiment's result. No
139
+ // cookie means the arm is unknowable, so none is sent.
140
+ const visitorId = readCookie(request, VISITOR_COOKIE);
141
+ const experiments: RunningExperiment[] =
142
+ visitorId && config.getRunningExperiments
143
+ ? await config.getRunningExperiments().catch(() => [])
144
+ : [];
145
+
96
146
  // The site key is stamped here, never trusted from the client: a browser must
97
147
  // not be able to write events into another site's namespace by editing a
98
148
  // payload. Anything the client sent under that key is overwritten.
99
- const stamped = events.map((e) => ({
100
- ...(e && typeof e === "object" ? e : {}),
101
- siteKey: config.siteKey,
102
- }));
149
+ const stamped = events
150
+ .filter(
151
+ (e): e is Record<string, unknown> =>
152
+ !!e &&
153
+ typeof e === "object" &&
154
+ typeof (e as { path?: unknown }).path === "string" &&
155
+ (e as { path: string }).path.startsWith("/"),
156
+ )
157
+ .map((e) => {
158
+ const { variant: _clientVariant, ...rest } = e;
159
+ const path = (rest.path as string).split("?")[0];
160
+ const arm = visitorId ? resolveVariant(path, visitorId, experiments) : null;
161
+ return {
162
+ ...rest,
163
+ ...(visitorId ? { visitorId } : {}),
164
+ ...(arm?.experimentKey ? { variant: arm.variant } : {}),
165
+ siteKey: config.siteKey,
166
+ };
167
+ });
168
+ if (stamped.length === 0) return noContent();
103
169
 
104
170
  try {
105
171
  const res = await fetch(url, {
@@ -1,37 +1,37 @@
1
- /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.0";
3
-
4
- export {
5
- resolveVariant,
6
- bucketFor,
7
- CONTROL,
8
- VARIANT,
9
- VISITOR_COOKIE,
10
- type RunningExperiment,
11
- } from "./bucketing";
12
-
13
- export {
14
- trackFunnelEvent,
15
- registerExperiments,
16
- flushFunnelEvents,
17
- currentSessionId,
18
- funnelAnalyticsEnabled,
19
- consentGranted,
20
- resetFunnelAnalytics,
21
- type FunnelEventProps,
22
- } from "./collector";
23
-
24
- export { useFormAnalytics, type FormAnalytics, type FormAnalyticsOptions } from "./use-form-analytics";
25
-
26
- export {
27
- FunnelAnalytics,
28
- EngagementTracking,
29
- AbArm,
30
- THRESHOLDS,
31
- crossedThresholds,
32
- labelFor,
33
- } from "./components";
34
-
35
- export { createCollectHandler } from "./collect-handler";
36
-
37
- export { withVisitorCookie } from "./proxy";
1
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "2.3.0";
3
+
4
+ export {
5
+ resolveVariant,
6
+ bucketFor,
7
+ CONTROL,
8
+ VARIANT,
9
+ VISITOR_COOKIE,
10
+ type RunningExperiment,
11
+ } from "./bucketing";
12
+
13
+ export {
14
+ trackFunnelEvent,
15
+ registerExperiments,
16
+ flushFunnelEvents,
17
+ currentSessionId,
18
+ funnelAnalyticsEnabled,
19
+ consentGranted,
20
+ resetFunnelAnalytics,
21
+ type FunnelEventProps,
22
+ } from "./collector";
23
+
24
+ export { useFormAnalytics, type FormAnalytics, type FormAnalyticsOptions } from "./use-form-analytics";
25
+
26
+ export {
27
+ FunnelAnalytics,
28
+ EngagementTracking,
29
+ AbArm,
30
+ THRESHOLDS,
31
+ crossedThresholds,
32
+ labelFor,
33
+ } from "./components";
34
+
35
+ export { createCollectHandler } from "./collect-handler";
36
+
37
+ export { withVisitorCookie } from "./proxy";
@@ -1,14 +1,14 @@
1
- /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.0";
3
-
4
- export {
5
- getAttribution,
6
- captureAttribution,
7
- getAttributionPayload,
8
- computeAttribution,
9
- flattenAttribution,
10
- hasCampaignSignal,
11
- } from "./store";
12
- export type { Attribution, Touch, AttributionPayload } from "./store";
13
-
14
- export { sessionIdField, SESSION_ID_FIELD } from "./crm";
1
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "2.3.0";
3
+
4
+ export {
5
+ getAttribution,
6
+ captureAttribution,
7
+ getAttributionPayload,
8
+ computeAttribution,
9
+ flattenAttribution,
10
+ hasCampaignSignal,
11
+ } from "./store";
12
+ export type { Attribution, Touch, AttributionPayload } from "./store";
13
+
14
+ export { sessionIdField, SESSION_ID_FIELD } from "./crm";
@@ -1,66 +1,66 @@
1
- /**
2
- * Site brand — the theme boundary (Studio spec 2026-10-08, phase 5).
3
- *
4
- * A client site declares its brand in CODE, in its root layout:
5
- *
6
- * const brand = defineSiteBrand({ theme: "acme", style: "flat" });
7
- * <html lang="en" {...brandRootAttributes(brand)}>
8
- *
9
- * and passes the same `brand` to `createManifestHandler`, so Studio records it.
10
- *
11
- * FAIL CLOSED. With no `data-theme`, @web-my-money/tokens falls back to WMM's
12
- * palette (CSS) and to "dark" (JS). A client site must never get there by
13
- * omission, so every invalid input THROWS — and because this runs while the root
14
- * layout renders, a throw is a failed build, not a quietly wrong page.
15
- *
16
- * Pure on purpose: no `server-only`, no Next imports, safe in any component.
17
- */
18
-
19
- export const PACKAGE_VERSION = "2.1.0";
20
-
21
- /** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
22
- export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
23
-
24
- const STYLES = ["flat", "glass"] as const;
25
- const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
26
-
27
- export type SiteBrand = {
28
- readonly theme: string;
29
- readonly style: (typeof STYLES)[number];
30
- readonly wmmSite: boolean;
31
- };
32
-
33
- export function defineSiteBrand(input: {
34
- theme: string;
35
- style: (typeof STYLES)[number];
36
- wmmSite?: boolean;
37
- }): SiteBrand {
38
- const { theme, style } = input;
39
- const wmmSite = input.wmmSite === true;
40
-
41
- // Refused rather than trimmed/lowercased: normalising here would make the id
42
- // in the code and the id in the stylesheet two different strings.
43
- if (typeof theme !== "string" || !THEME_ID.test(theme)) {
44
- throw new Error(
45
- `defineSiteBrand: theme ${JSON.stringify(theme)} is not a valid theme id ` +
46
- "(lowercase letters, digits and dashes, starting with a letter).",
47
- );
48
- }
49
- if ((WMM_THEMES as readonly string[]).includes(theme) && !wmmSite) {
50
- throw new Error(
51
- `defineSiteBrand: "${theme}" is WMM's own theme. A client site needs its own ` +
52
- "theme from the design system (gen-client-theme). Only a WMM site may pass wmmSite: true.",
53
- );
54
- }
55
- if (!(STYLES as readonly string[]).includes(style)) {
56
- throw new Error(`defineSiteBrand: style must be "flat" or "glass", got ${JSON.stringify(style)}.`);
57
- }
58
- return Object.freeze({ theme, style, wmmSite });
59
- }
60
-
61
- export function brandRootAttributes(brand: SiteBrand): {
62
- "data-theme": string;
63
- "data-style": string;
64
- } {
65
- return { "data-theme": brand.theme, "data-style": brand.style };
66
- }
1
+ /**
2
+ * Site brand — the theme boundary (Studio spec 2026-10-08, phase 5).
3
+ *
4
+ * A client site declares its brand in CODE, in its root layout:
5
+ *
6
+ * const brand = defineSiteBrand({ theme: "acme", style: "flat" });
7
+ * <html lang="en" {...brandRootAttributes(brand)}>
8
+ *
9
+ * and passes the same `brand` to `createManifestHandler`, so Studio records it.
10
+ *
11
+ * FAIL CLOSED. With no `data-theme`, @web-my-money/tokens falls back to WMM's
12
+ * palette (CSS) and to "dark" (JS). A client site must never get there by
13
+ * omission, so every invalid input THROWS — and because this runs while the root
14
+ * layout renders, a throw is a failed build, not a quietly wrong page.
15
+ *
16
+ * Pure on purpose: no `server-only`, no Next imports, safe in any component.
17
+ */
18
+
19
+ export const PACKAGE_VERSION = "2.3.0";
20
+
21
+ /** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
22
+ export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
23
+
24
+ const STYLES = ["flat", "glass"] as const;
25
+ const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
26
+
27
+ export type SiteBrand = {
28
+ readonly theme: string;
29
+ readonly style: (typeof STYLES)[number];
30
+ readonly wmmSite: boolean;
31
+ };
32
+
33
+ export function defineSiteBrand(input: {
34
+ theme: string;
35
+ style: (typeof STYLES)[number];
36
+ wmmSite?: boolean;
37
+ }): SiteBrand {
38
+ const { theme, style } = input;
39
+ const wmmSite = input.wmmSite === true;
40
+
41
+ // Refused rather than trimmed/lowercased: normalising here would make the id
42
+ // in the code and the id in the stylesheet two different strings.
43
+ if (typeof theme !== "string" || !THEME_ID.test(theme)) {
44
+ throw new Error(
45
+ `defineSiteBrand: theme ${JSON.stringify(theme)} is not a valid theme id ` +
46
+ "(lowercase letters, digits and dashes, starting with a letter).",
47
+ );
48
+ }
49
+ if ((WMM_THEMES as readonly string[]).includes(theme) && !wmmSite) {
50
+ throw new Error(
51
+ `defineSiteBrand: "${theme}" is WMM's own theme. A client site needs its own ` +
52
+ "theme from the design system (gen-client-theme). Only a WMM site may pass wmmSite: true.",
53
+ );
54
+ }
55
+ if (!(STYLES as readonly string[]).includes(style)) {
56
+ throw new Error(`defineSiteBrand: style must be "flat" or "glass", got ${JSON.stringify(style)}.`);
57
+ }
58
+ return Object.freeze({ theme, style, wmmSite });
59
+ }
60
+
61
+ export function brandRootAttributes(brand: SiteBrand): {
62
+ "data-theme": string;
63
+ "data-style": string;
64
+ } {
65
+ return { "data-theme": brand.theme, "data-style": brand.style };
66
+ }